From 0d44b61277197418acbb93e1c9cae5eeb9bdc36c Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 23 Sep 2026 12:58:49 -0400 Subject: [PATCH 1/8] feat(clerk-js): add experimental WebMCP tools for browser agents Co-Authored-By: Claude Opus 5.5 --- .changeset/experimental-webmcp-tools.md | 6 + packages/clerk-js/bundlewatch.config.json | 4 +- packages/clerk-js/src/core/clerk.ts | 9 + .../modules/webmcp/__tests__/webmcp.test.ts | 293 +++++++++++++++ .../clerk-js/src/core/modules/webmcp/index.ts | 338 ++++++++++++++++++ packages/shared/src/types/clerk.ts | 1 + 6 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 .changeset/experimental-webmcp-tools.md create mode 100644 packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts create mode 100644 packages/clerk-js/src/core/modules/webmcp/index.ts diff --git a/.changeset/experimental-webmcp-tools.md b/.changeset/experimental-webmcp-tools.md new file mode 100644 index 00000000000..18574cd0dac --- /dev/null +++ b/.changeset/experimental-webmcp-tools.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add an experimental `webmcp` option (`experimental: { webmcp: true }`). In browsers that support [WebMCP](https://webmachinelearning.github.io/webmcp/), Clerk registers tools that let browser agents check auth state, sign in, submit verification codes, switch organizations and sign out. The tools never take a password or return a token; password and passkey sign-in go through the browser's own prompts. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index ce853a560ba..2165f1a5c21 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,8 +2,8 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "556KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "127.5KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "325.25KB" }, { "path": "./dist/clerk.native.js", "maxSize": "82KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f5eaa05aa92..23cb060442a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3622,6 +3622,7 @@ export class Clerk implements ClerkInterface { this.#clearClerkQueryParams(); this.#handleImpersonationFab(); this.#handleKeylessPrompt(); + this.#handleWebMcp(); this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready'); }; @@ -3853,6 +3854,14 @@ export class Clerk implements ClerkInterface { } }; + #handleWebMcp = () => { + if (this.#options.experimental?.webmcp) { + void import(/* webpackChunkName: "webmcp" */ './modules/webmcp') + .then(({ registerWebMcpTools }) => registerWebMcpTools(this)) + .catch(noop); + } + }; + #buildUrl = ( key: 'signInUrl' | 'signUpUrl', options: RedirectOptions, diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts new file mode 100644 index 00000000000..bb2d3bd53a8 --- /dev/null +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -0,0 +1,293 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Clerk } from '../../../clerk'; +import { registerWebMcpTools } from '..'; + +type Tool = { + name: string; + inputSchema?: { properties: { method?: { enum: string[] } } }; + execute: (input: Record) => Promise>; +}; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +const createModelContext = () => { + const tools = new Map(); + return { + tools, + registerTool: vi.fn((tool: Tool, options?: { signal?: AbortSignal }) => { + tools.set(tool.name, tool); + options?.signal?.addEventListener('abort', () => tools.delete(tool.name)); + return Promise.resolve(); + }), + }; +}; + +const createClerk = () => { + let listener: ((resources: { session: unknown }) => void) | undefined; + const signIn = { + status: null as string | null, + createdSessionId: null as string | null, + supportedFirstFactors: null as unknown[] | null, + supportedSecondFactors: null as unknown[] | null, + firstFactorVerification: { strategy: null as string | null }, + secondFactorVerification: { strategy: null as string | null }, + create: vi.fn(), + prepareFirstFactor: vi.fn(), + attemptFirstFactor: vi.fn(), + prepareSecondFactor: vi.fn(), + attemptSecondFactor: vi.fn(), + authenticateWithRedirect: vi.fn(), + authenticateWithPasskey: vi.fn(), + createEmailLinkFlow: vi.fn(), + }; + const clerk = { + client: { signIn }, + session: null as unknown, + user: null as unknown, + organization: null, + setActive: vi.fn(({ session }: { session?: string }) => { + if (session) { + clerk.session = { id: session }; + } + return Promise.resolve(); + }), + signOut: vi.fn(), + addListener: vi.fn((callback: typeof listener) => { + listener = callback; + callback?.({ session: clerk.session }); + return () => {}; + }), + __internal_getOption: vi.fn(() => undefined), + __internal_environment: { + displayConfig: { applicationName: 'Acme', signInUrl: 'https://accounts.acme.com/sign-in' }, + organizationSettings: { enabled: true }, + userSettings: { + attributes: { + email_address: { used_for_first_factor: true, first_factors: ['email_code', 'email_link'] }, + phone_number: { used_for_first_factor: false, first_factors: ['phone_code'] }, + passkey: { enabled: true }, + }, + authenticatableSocialStrategies: ['oauth_google'], + enterpriseSSO: { enabled: false }, + instanceIsPasswordBased: true, + }, + }, + }; + + const update = (state: Partial) => () => Promise.resolve(Object.assign(signIn, state)); + const signInAs = (user: unknown) => { + clerk.session = { id: 'sess_1' }; + clerk.user = user; + listener?.({ session: clerk.session }); + }; + + return { clerk, signIn, update, signInAs }; +}; + +describe('registerWebMcpTools', () => { + let modelContext: ReturnType; + let setup: ReturnType; + + const register = async () => { + registerWebMcpTools(setup.clerk as unknown as Clerk); + await flush(); + }; + + const call = (name: string, input: Record = {}) => { + const tool = modelContext.tools.get(name); + if (!tool) { + throw new Error(`${name} is not registered`); + } + return tool.execute(input); + }; + + beforeEach(() => { + modelContext = createModelContext(); + setup = createClerk(); + Object.defineProperty(document, 'modelContext', { value: modelContext, configurable: true }); + }); + + afterEach(() => { + Reflect.deleteProperty(document, 'modelContext'); + Reflect.deleteProperty(navigator, 'credentials'); + }); + + it('does nothing when the browser has no model context', () => { + Reflect.deleteProperty(document, 'modelContext'); + + registerWebMcpTools(setup.clerk as unknown as Clerk); + + expect(setup.clerk.addListener).not.toHaveBeenCalled(); + }); + + it('registers the signed-out tools with the sign-in methods the instance accepts', async () => { + await register(); + + expect([...modelContext.tools.keys()]).toEqual(['clerk_get_auth_state', 'clerk_sign_in', 'clerk_submit_code']); + expect(modelContext.tools.get('clerk_sign_in')?.inputSchema?.properties.method?.enum).toEqual([ + 'passkey', + 'oauth_google', + 'email_code', + 'email_link', + 'password', + ]); + await expect(call('clerk_get_auth_state')).resolves.toEqual({ + signedIn: false, + signInMethods: ['passkey', 'oauth_google', 'email_code', 'email_link', 'password'], + }); + }); + + it('swaps to the signed-in tools once a session exists', async () => { + await register(); + + setup.signInAs({ organizationMemberships: [] }); + await flush(); + + expect([...modelContext.tools.keys()]).toEqual([ + 'clerk_get_auth_state', + 'clerk_switch_organization', + 'clerk_sign_out', + ]); + }); + + it('signs in with an email code', async () => { + const { signIn, update } = setup; + const factor = { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'j***@acme.com' }; + signIn.create.mockImplementation(update({ status: 'needs_first_factor', supportedFirstFactors: [factor] })); + signIn.prepareFirstFactor.mockImplementation(update({ firstFactorVerification: { strategy: 'email_code' } })); + signIn.attemptFirstFactor.mockImplementation(update({ status: 'complete', createdSessionId: 'sess_1' })); + await register(); + + await expect(call('clerk_sign_in', { method: 'email_code', identifier: 'jane@acme.com' })).resolves.toEqual({ + status: 'needs_code', + sentTo: 'j***@acme.com', + next: 'clerk_submit_code', + }); + expect(signIn.create).toHaveBeenCalledWith({ identifier: 'jane@acme.com' }); + expect(signIn.prepareFirstFactor).toHaveBeenCalledWith(factor); + + await expect(call('clerk_submit_code', { code: '424242' })).resolves.toEqual({ status: 'signed_in' }); + expect(signIn.attemptFirstFactor).toHaveBeenCalledWith({ strategy: 'email_code', code: '424242' }); + expect(setup.clerk.setActive).toHaveBeenCalledWith({ session: 'sess_1' }); + }); + + it('asks for an authenticator app code when the account has MFA', async () => { + const { signIn, update } = setup; + signIn.status = 'needs_first_factor'; + signIn.firstFactorVerification.strategy = 'email_code'; + signIn.attemptFirstFactor.mockImplementation( + update({ + status: 'needs_second_factor', + supportedSecondFactors: [{ strategy: 'phone_code', safeIdentifier: '+1***' }, { strategy: 'totp' }], + }), + ); + signIn.attemptSecondFactor.mockImplementation(update({ status: 'complete', createdSessionId: 'sess_1' })); + await register(); + + await expect(call('clerk_submit_code', { code: '424242' })).resolves.toEqual({ + status: 'needs_code', + codeFrom: 'authenticator_app', + next: 'clerk_submit_code', + }); + expect(signIn.prepareSecondFactor).not.toHaveBeenCalled(); + + await expect(call('clerk_submit_code', { code: '123456' })).resolves.toEqual({ status: 'signed_in' }); + expect(signIn.attemptSecondFactor).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); + }); + + it('sends a code when the sign-in needs a new-device check', async () => { + const { signIn, update } = setup; + const factor = { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'j***@acme.com' }; + signIn.authenticateWithPasskey.mockImplementation( + update({ status: 'needs_client_trust', supportedSecondFactors: [factor] }), + ); + await register(); + + await expect(call('clerk_sign_in', { method: 'passkey' })).resolves.toEqual({ + status: 'needs_code', + sentTo: 'j***@acme.com', + next: 'clerk_submit_code', + }); + expect(signIn.authenticateWithPasskey).toHaveBeenCalledWith({ flow: 'discoverable' }); + expect(signIn.prepareSecondFactor).toHaveBeenCalledWith(factor); + }); + + it('redirects to the provider for OAuth and comes back to the current page', async () => { + await register(); + + await expect(call('clerk_sign_in', { method: 'oauth_google' })).resolves.toEqual({ + status: 'redirecting', + next: 'clerk_get_auth_state', + }); + expect(setup.signIn.authenticateWithRedirect).toHaveBeenCalledWith({ + strategy: 'oauth_google', + identifier: undefined, + redirectUrl: 'https://accounts.acme.com/sign-in#/sso-callback', + redirectUrlComplete: window.location.href, + }); + }); + + it('signs in with a saved password without returning it', async () => { + const get = vi.fn().mockResolvedValue({ id: 'jane@acme.com', password: 'hunter2' }); + Object.defineProperty(navigator, 'credentials', { value: { get }, configurable: true }); + setup.signIn.create.mockImplementation(setup.update({ status: 'complete', createdSessionId: 'sess_1' })); + await register(); + + const result = await call('clerk_sign_in', { method: 'password' }); + + expect(result).toEqual({ status: 'signed_in' }); + expect(JSON.stringify(result)).not.toContain('hunter2'); + expect(get).toHaveBeenCalledWith({ password: true, mediation: 'required' }); + expect(setup.signIn.create).toHaveBeenCalledWith({ + strategy: 'password', + identifier: 'jane@acme.com', + password: 'hunter2', + }); + }); + + it('rejects methods the instance has not enabled', async () => { + await register(); + + await expect(call('clerk_sign_in', { method: 'phone_code', identifier: '+15555550100' })).resolves.toMatchObject({ + status: 'error', + }); + expect(setup.signIn.create).not.toHaveBeenCalled(); + }); + + it('returns Clerk API errors to the agent', async () => { + const { signIn } = setup; + signIn.status = 'needs_first_factor'; + signIn.firstFactorVerification.strategy = 'email_code'; + signIn.attemptFirstFactor.mockRejectedValue( + new ClerkAPIResponseError('Incorrect code', { + data: [{ code: 'form_code_incorrect', message: 'is incorrect', long_message: 'Incorrect code' }], + status: 422, + }), + ); + await register(); + + await expect(call('clerk_submit_code', { code: '000000' })).resolves.toEqual({ + status: 'error', + code: 'form_code_incorrect', + message: 'Incorrect code', + }); + }); + + it('only switches to organizations the user belongs to', async () => { + await register(); + setup.signInAs({ + organizationMemberships: [{ organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, role: 'org:admin' }], + }); + await flush(); + + await expect(call('clerk_switch_organization', { organization: 'globex' })).resolves.toMatchObject({ + status: 'error', + }); + expect(setup.clerk.setActive).not.toHaveBeenCalled(); + + await call('clerk_switch_organization', { organization: 'acme' }); + expect(setup.clerk.setActive).toHaveBeenCalledWith({ organization: 'org_1' }); + }); +}); diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts new file mode 100644 index 00000000000..152a6437f06 --- /dev/null +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -0,0 +1,338 @@ +import type { ClerkAPIError, OAuthStrategy, SignInResource, SignInSecondFactor } from '@clerk/shared/types'; + +import type { Clerk } from '../../clerk'; + +type ToolResult = Record; + +type WebMcpTool = { + name: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean }; + execute: (input: Record) => Promise; +}; + +type ModelContext = { + registerTool: (tool: WebMcpTool, options?: { signal?: AbortSignal }) => Promise | void; +}; + +type SavedPassword = Credential & { password?: string }; + +const SECOND_FACTOR_PRIORITY = ['totp', 'phone_code', 'email_code', 'backup_code']; + +const getModelContext = (): ModelContext | undefined => + (document as Document & { modelContext?: ModelContext }).modelContext ?? + (navigator as Navigator & { modelContext?: ModelContext }).modelContext; + +const toError = (error: unknown): ToolResult => { + const [first] = (error as { errors?: ClerkAPIError[] } | undefined)?.errors ?? []; + if (first) { + return { status: 'error', code: first.code, message: first.longMessage || first.message }; + } + return { status: 'error', message: error instanceof Error ? error.message : String(error) }; +}; + +const defineTool = (tool: WebMcpTool): WebMcpTool => ({ + ...tool, + execute: input => tool.execute(input ?? {}).catch(toError), +}); + +const getAppName = (clerk: Clerk) => clerk.__internal_environment?.displayConfig.applicationName || 'this app'; + +const getSignInCallbackUrl = (clerk: Clerk, hash: string) => { + const signInUrl = clerk.__internal_getOption('signInUrl') || clerk.__internal_environment?.displayConfig.signInUrl; + const url = new URL(signInUrl || '', window.location.href); + url.hash = hash; + return url.href; +}; + +const getSignInMethods = (clerk: Clerk): string[] => { + const userSettings = clerk.__internal_environment?.userSettings; + if (!userSettings) { + return []; + } + + const { attributes } = userSettings; + const firstFactors = [ + ...(attributes.email_address?.used_for_first_factor ? attributes.email_address.first_factors : []), + ...(attributes.phone_number?.used_for_first_factor ? attributes.phone_number.first_factors : []), + ]; + + return [ + ...(attributes.passkey?.enabled ? ['passkey'] : []), + ...userSettings.authenticatableSocialStrategies, + ...(userSettings.enterpriseSSO?.enabled ? ['enterprise_sso'] : []), + ...firstFactors.filter(strategy => ['email_code', 'email_link', 'phone_code'].includes(strategy)), + ...(userSettings.instanceIsPasswordBased ? ['password'] : []), + ]; +}; + +const getAuthState = (clerk: Clerk): ToolResult => { + const { session, user, organization } = clerk; + if (!session || !user) { + return { signedIn: false, signInMethods: getSignInMethods(clerk) }; + } + + return { + signedIn: true, + user: { id: user.id, name: user.fullName, email: user.primaryEmailAddress?.emailAddress ?? null }, + activeOrganizationId: organization?.id ?? null, + organizations: user.organizationMemberships.map(membership => ({ + id: membership.organization.id, + name: membership.organization.name, + slug: membership.organization.slug, + role: membership.role, + })), + pendingTask: session.currentTask?.key ?? null, + }; +}; + +const getSecondFactor = (signIn: SignInResource): SignInSecondFactor | undefined => { + const factors = signIn.supportedSecondFactors ?? []; + return SECOND_FACTOR_PRIORITY.map(strategy => factors.find(factor => factor.strategy === strategy)).find(Boolean); +}; + +const continueSignIn = async (clerk: Clerk, signIn: SignInResource): Promise => { + if (signIn.status === 'complete') { + await clerk.setActive({ session: signIn.createdSessionId }); + const task = clerk.session?.currentTask?.key; + return task ? { status: 'session_task', task } : { status: 'signed_in' }; + } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust') { + const factor = getSecondFactor(signIn); + if (factor?.strategy === 'phone_code' || factor?.strategy === 'email_code') { + await signIn.prepareSecondFactor(factor); + return { status: 'needs_code', sentTo: factor.safeIdentifier, next: 'clerk_submit_code' }; + } + if (factor) { + return { + status: 'needs_code', + codeFrom: factor.strategy === 'totp' ? 'authenticator_app' : 'backup_codes', + next: 'clerk_submit_code', + }; + } + } + + return { + status: 'needs_user_action', + message: 'Finish signing in with the sign-in form on the page.', + signInStatus: signIn.status, + }; +}; + +const signInWithSavedPassword = async (clerk: Clerk, signIn: SignInResource): Promise => { + const credential = (await navigator.credentials + ?.get({ password: true, mediation: 'required' } as CredentialRequestOptions) + .catch(() => null)) as SavedPassword | null | undefined; + + if (!credential?.password) { + return { + status: 'needs_user_action', + message: 'No saved password was chosen. The person needs to type their password into the sign-in form.', + }; + } + + return continueSignIn( + clerk, + await signIn.create({ strategy: 'password', identifier: credential.id, password: credential.password }), + ); +}; + +const signInWithIdentifier = async ( + clerk: Clerk, + signIn: SignInResource, + method: string, + identifier: string, +): Promise => { + const created = await signIn.create({ identifier }); + const factor = created.supportedFirstFactors?.find(supported => supported.strategy === method); + + if (factor?.strategy === 'email_link') { + const { startEmailLinkFlow } = created.createEmailLinkFlow(); + void startEmailLinkFlow({ + emailAddressId: factor.emailAddressId, + redirectUrl: getSignInCallbackUrl(clerk, '/verify'), + }) + .then(result => (result.status === 'complete' ? clerk.setActive({ session: result.createdSessionId }) : null)) + .catch(() => null); + return { + status: 'needs_user_action', + message: `Open the sign-in link sent to ${factor.safeIdentifier} in this browser, then call clerk_get_auth_state.`, + }; + } + + if (factor?.strategy === 'email_code' || factor?.strategy === 'phone_code') { + await created.prepareFirstFactor(factor); + return { status: 'needs_code', sentTo: factor.safeIdentifier, next: 'clerk_submit_code' }; + } + + return { status: 'error', message: `This account can't sign in with ${method}.` }; +}; + +const getAuthStateTool = (clerk: Clerk) => + defineTool({ + name: 'clerk_get_auth_state', + description: `Returns whether the person is signed in to ${getAppName(clerk)}. When signed out, lists the sign-in methods this app accepts. When signed in, returns the user, their organizations and any pending session task.`, + annotations: { readOnlyHint: true, untrustedContentHint: true }, + execute: () => Promise.resolve(getAuthState(clerk)), + }); + +const getSignInTool = (clerk: Clerk) => { + const methods = getSignInMethods(clerk); + return defineTool({ + name: 'clerk_sign_in', + description: `Starts signing in to ${getAppName(clerk)}. Never takes a password: "password" opens the browser's saved-password picker, "passkey" opens the browser's passkey prompt, and "oauth_*" or "enterprise_sso" redirect to the provider. Returns a status: signed_in, needs_code, needs_user_action, redirecting, session_task or error.`, + inputSchema: { + type: 'object', + properties: { + method: { type: 'string', enum: methods }, + identifier: { + type: 'string', + description: + 'Email address, phone number or username. Required for email_code, email_link, phone_code and enterprise_sso.', + }, + }, + required: ['method'], + }, + execute: async ({ method, identifier }) => { + const signIn = clerk.client?.signIn; + if (!signIn || typeof method !== 'string' || !methods.includes(method)) { + return { status: 'error', message: `Use one of: ${methods.join(', ')}.` }; + } + + if (method === 'passkey') { + return continueSignIn(clerk, await signIn.authenticateWithPasskey({ flow: 'discoverable' })); + } + + if (method === 'password') { + return signInWithSavedPassword(clerk, signIn); + } + + if (method.startsWith('oauth_') || method === 'enterprise_sso') { + await signIn.authenticateWithRedirect({ + strategy: method as OAuthStrategy | 'enterprise_sso', + identifier: typeof identifier === 'string' ? identifier : undefined, + redirectUrl: getSignInCallbackUrl(clerk, '/sso-callback'), + redirectUrlComplete: window.location.href, + }); + return { status: 'redirecting', next: 'clerk_get_auth_state' }; + } + + if (typeof identifier !== 'string' || !identifier) { + return { status: 'error', message: `${method} needs an identifier.` }; + } + + return signInWithIdentifier(clerk, signIn, method, identifier); + }, + }); +}; + +const getSubmitCodeTool = (clerk: Clerk) => + defineTool({ + name: 'clerk_submit_code', + description: + 'Submits a verification code for the sign-in in progress: an email or SMS code, an authenticator app code, or a backup code. The code only works for the sign-in started in this browser.', + inputSchema: { + type: 'object', + properties: { code: { type: 'string' } }, + required: ['code'], + }, + execute: async ({ code }) => { + const signIn = clerk.client?.signIn; + if (!signIn || typeof code !== 'string') { + return { status: 'error', message: 'Pass the code as a string.' }; + } + + if (signIn.status === 'needs_first_factor') { + const strategy = signIn.firstFactorVerification.strategy; + if (strategy === 'email_code' || strategy === 'phone_code') { + return continueSignIn(clerk, await signIn.attemptFirstFactor({ strategy, code })); + } + } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust') { + const strategy = signIn.secondFactorVerification.strategy ?? getSecondFactor(signIn)?.strategy; + if ( + strategy === 'totp' || + strategy === 'phone_code' || + strategy === 'email_code' || + strategy === 'backup_code' + ) { + return continueSignIn(clerk, await signIn.attemptSecondFactor({ strategy, code })); + } + } + + return { status: 'error', message: 'No sign-in is waiting for a code. Call clerk_sign_in first.' }; + }, + }); + +const getSwitchOrganizationTool = (clerk: Clerk) => + defineTool({ + name: 'clerk_switch_organization', + description: + "Makes one of the user's organizations active. Pass an organization id or slug from clerk_get_auth_state.", + inputSchema: { + type: 'object', + properties: { organization: { type: 'string' } }, + required: ['organization'], + }, + execute: async ({ organization }) => { + const membership = clerk.user?.organizationMemberships.find( + ({ organization: org }) => org.id === organization || org.slug === organization, + ); + if (!membership) { + return { status: 'error', message: 'The user is not a member of that organization.' }; + } + + await clerk.setActive({ organization: membership.organization.id }); + return getAuthState(clerk); + }, + }); + +const getSignOutTool = (clerk: Clerk) => + defineTool({ + name: 'clerk_sign_out', + description: `Signs the person out of ${getAppName(clerk)}.`, + execute: async () => { + await clerk.signOut(); + return { status: 'signed_out' }; + }, + }); + +export const registerWebMcpTools = (clerk: Clerk): void => { + const modelContext = getModelContext(); + if (!modelContext) { + return; + } + + const register = (tools: WebMcpTool[], signal?: AbortSignal) => { + tools.forEach(tool => { + void Promise.resolve() + .then(() => modelContext.registerTool(tool, { signal })) + .catch(() => null); + }); + }; + + register([getAuthStateTool(clerk)]); + + let signedIn: boolean | undefined; + let controller: AbortController | undefined; + clerk.addListener(({ session }) => { + if (signedIn === Boolean(session)) { + return; + } + + signedIn = Boolean(session); + controller?.abort(); + controller = new AbortController(); + + const organizationsEnabled = clerk.__internal_environment?.organizationSettings.enabled; + register( + signedIn + ? [...(organizationsEnabled ? [getSwitchOrganizationTool(clerk)] : []), getSignOutTool(clerk)] + : [getSignInTool(clerk), getSubmitCodeTool(clerk)], + controller.signal, + ); + }); +}; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index ec23d03de7a..8e5d979e402 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1652,6 +1652,7 @@ export type ClerkOptions = ClerkOptionsNavigation & */ rethrowOfflineNetworkErrors: boolean; commerce: boolean; + webmcp: boolean; /** * When set to `'headless'`, Clerk will skip script/chunk loading and initialize * directly with the provided Clerk instance. Used by React Native / Expo. From 8535101545152e550c9c69533a004b7bc391624f Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 23 Sep 2026 22:46:36 -0400 Subject: [PATCH 2/8] feat(clerk-js): point agents to other sign-in methods when no passkey or saved password is used Co-Authored-By: Claude Opus 5.5 --- .../modules/webmcp/__tests__/webmcp.test.ts | 40 ++++++++++ .../clerk-js/src/core/modules/webmcp/index.ts | 75 ++++++++++++++++--- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index bb2d3bd53a8..8956e5b797d 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -54,6 +54,8 @@ const createClerk = () => { return Promise.resolve(); }), signOut: vi.fn(), + openSignIn: vi.fn(), + buildSignInUrl: vi.fn(() => 'https://accounts.acme.com/sign-in'), addListener: vi.fn((callback: typeof listener) => { listener = callback; callback?.({ session: clerk.session }); @@ -247,6 +249,44 @@ describe('registerWebMcpTools', () => { }); }); + it('opens the sign-in form when no saved password is chosen', async () => { + Object.defineProperty(navigator, 'credentials', { + value: { get: vi.fn().mockResolvedValue(null) }, + configurable: true, + }); + await register(); + + await expect(call('clerk_sign_in', { method: 'password', identifier: 'jane@acme.com' })).resolves.toMatchObject({ + status: 'needs_user_action', + signInForm: 'opened', + otherMethods: ['passkey', 'oauth_google', 'email_code', 'email_link'], + }); + expect(setup.clerk.openSignIn).toHaveBeenCalledWith({ initialValues: { emailAddress: 'jane@acme.com' } }); + + setup.clerk.openSignIn.mockImplementation(() => { + throw new Error('Clerk was not loaded with Ui components'); + }); + await expect(call('clerk_sign_in', { method: 'password' })).resolves.toMatchObject({ + status: 'needs_user_action', + signInUrl: 'https://accounts.acme.com/sign-in', + }); + }); + + it('suggests other methods when no passkey is used', async () => { + setup.signIn.authenticateWithPasskey.mockRejectedValue( + Object.assign(new Error('The operation either timed out or was not allowed.'), { + code: 'passkey_retrieval_cancelled', + }), + ); + await register(); + + await expect(call('clerk_sign_in', { method: 'passkey' })).resolves.toMatchObject({ + status: 'error', + code: 'passkey_retrieval_cancelled', + otherMethods: ['oauth_google', 'email_code', 'email_link', 'password'], + }); + }); + it('rejects methods the instance has not enabled', async () => { await register(); diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index 152a6437f06..b78c6bcfdc0 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -1,4 +1,10 @@ -import type { ClerkAPIError, OAuthStrategy, SignInResource, SignInSecondFactor } from '@clerk/shared/types'; +import type { + ClerkAPIError, + OAuthStrategy, + SignInInitialValues, + SignInResource, + SignInSecondFactor, +} from '@clerk/shared/types'; import type { Clerk } from '../../clerk'; @@ -29,7 +35,11 @@ const toError = (error: unknown): ToolResult => { if (first) { return { status: 'error', code: first.code, message: first.longMessage || first.message }; } - return { status: 'error', message: error instanceof Error ? error.message : String(error) }; + return { + status: 'error', + code: (error as { code?: string } | undefined)?.code, + message: error instanceof Error ? error.message : String(error), + }; }; const defineTool = (tool: WebMcpTool): WebMcpTool => ({ @@ -121,7 +131,32 @@ const continueSignIn = async (clerk: Clerk, signIn: SignInResource): Promise => { +const getInitialValues = (identifier?: string): SignInInitialValues | undefined => { + if (!identifier) { + return undefined; + } + if (identifier.includes('@')) { + return { emailAddress: identifier }; + } + return identifier.startsWith('+') ? { phoneNumber: identifier } : { username: identifier }; +}; + +const openSignInForm = (clerk: Clerk, identifier?: string): ToolResult => { + const initialValues = getInitialValues(identifier); + try { + clerk.openSignIn({ initialValues }); + return { signInForm: 'opened' }; + } catch { + return { signInUrl: clerk.buildSignInUrl({ initialValues }) }; + } +}; + +const signInWithSavedPassword = async ( + clerk: Clerk, + signIn: SignInResource, + identifier: string | undefined, + otherMethods: string[], +): Promise => { const credential = (await navigator.credentials ?.get({ password: true, mediation: 'required' } as CredentialRequestOptions) .catch(() => null)) as SavedPassword | null | undefined; @@ -129,7 +164,10 @@ const signInWithSavedPassword = async (clerk: Clerk, signIn: SignInResource): Pr if (!credential?.password) { return { status: 'needs_user_action', - message: 'No saved password was chosen. The person needs to type their password into the sign-in form.', + message: + 'No saved password was chosen. The person needs to type their password into the sign-in form, or try another method.', + ...openSignInForm(clerk, identifier), + otherMethods, }; } @@ -182,7 +220,7 @@ const getSignInTool = (clerk: Clerk) => { const methods = getSignInMethods(clerk); return defineTool({ name: 'clerk_sign_in', - description: `Starts signing in to ${getAppName(clerk)}. Never takes a password: "password" opens the browser's saved-password picker, "passkey" opens the browser's passkey prompt, and "oauth_*" or "enterprise_sso" redirect to the provider. Returns a status: signed_in, needs_code, needs_user_action, redirecting, session_task or error.`, + description: `Starts signing in to ${getAppName(clerk)}. Never takes a password: "password" opens the browser's saved-password picker, "passkey" opens the browser's passkey prompt, and "oauth_*" or "enterprise_sso" redirect to the provider. Only use "passkey" or "password" if the person has one saved in this browser; otherwise start with another method. Returns a status: signed_in, needs_code, needs_user_action, redirecting, session_task or error.`, inputSchema: { type: 'object', properties: { @@ -201,29 +239,46 @@ const getSignInTool = (clerk: Clerk) => { return { status: 'error', message: `Use one of: ${methods.join(', ')}.` }; } + const otherMethods = methods.filter(other => other !== method); + const identifierValue = typeof identifier === 'string' && identifier ? identifier : undefined; + if (method === 'passkey') { - return continueSignIn(clerk, await signIn.authenticateWithPasskey({ flow: 'discoverable' })); + return signIn.authenticateWithPasskey({ flow: 'discoverable' }).then( + result => continueSignIn(clerk, result), + (error: unknown) => { + if ((error as { code?: string } | undefined)?.code !== 'passkey_retrieval_cancelled') { + throw error; + } + return { + status: 'error', + code: 'passkey_retrieval_cancelled', + message: + 'No passkey was used: none is saved in this browser, or the prompt was closed. Try another method.', + otherMethods, + }; + }, + ); } if (method === 'password') { - return signInWithSavedPassword(clerk, signIn); + return signInWithSavedPassword(clerk, signIn, identifierValue, otherMethods); } if (method.startsWith('oauth_') || method === 'enterprise_sso') { await signIn.authenticateWithRedirect({ strategy: method as OAuthStrategy | 'enterprise_sso', - identifier: typeof identifier === 'string' ? identifier : undefined, + identifier: identifierValue, redirectUrl: getSignInCallbackUrl(clerk, '/sso-callback'), redirectUrlComplete: window.location.href, }); return { status: 'redirecting', next: 'clerk_get_auth_state' }; } - if (typeof identifier !== 'string' || !identifier) { + if (!identifierValue) { return { status: 'error', message: `${method} needs an identifier.` }; } - return signInWithIdentifier(clerk, signIn, method, identifier); + return signInWithIdentifier(clerk, signIn, method, identifierValue); }, }); }; From e412d7ebdadea75b73d9f7c22c9b8fa45d516e68 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 24 Sep 2026 09:51:25 -0400 Subject: [PATCH 3/8] feat(clerk-js): return an account's sign-in methods when the requested one isn't available Co-Authored-By: Claude Opus 5.5 --- .../modules/webmcp/__tests__/webmcp.test.ts | 21 +++++++++++++++++++ .../clerk-js/src/core/modules/webmcp/index.ts | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index 8956e5b797d..019a7928de3 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -287,6 +287,27 @@ describe('registerWebMcpTools', () => { }); }); + it("returns the account's own methods when the requested one isn't available", async () => { + setup.signIn.create.mockImplementation( + setup.update({ + status: 'needs_first_factor', + supportedFirstFactors: [ + { strategy: 'oauth_google' }, + { strategy: 'password' }, + { strategy: 'reset_password_email_code' }, + ], + }), + ); + await register(); + + await expect(call('clerk_sign_in', { method: 'email_code', identifier: 'jane@acme.com' })).resolves.toEqual({ + status: 'error', + message: "This account can't sign in with email_code.", + otherMethods: ['oauth_google', 'password'], + }); + expect(setup.signIn.prepareFirstFactor).not.toHaveBeenCalled(); + }); + it('rejects methods the instance has not enabled', async () => { await register(); diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index b78c6bcfdc0..63d41569013 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -182,6 +182,7 @@ const signInWithIdentifier = async ( signIn: SignInResource, method: string, identifier: string, + methods: string[], ): Promise => { const created = await signIn.create({ identifier }); const factor = created.supportedFirstFactors?.find(supported => supported.strategy === method); @@ -205,7 +206,10 @@ const signInWithIdentifier = async ( return { status: 'needs_code', sentTo: factor.safeIdentifier, next: 'clerk_submit_code' }; } - return { status: 'error', message: `This account can't sign in with ${method}.` }; + const accountMethods = [...new Set(created.supportedFirstFactors?.map(supported => supported.strategy))].filter( + strategy => strategy !== method && methods.includes(strategy), + ); + return { status: 'error', message: `This account can't sign in with ${method}.`, otherMethods: accountMethods }; }; const getAuthStateTool = (clerk: Clerk) => @@ -278,7 +282,7 @@ const getSignInTool = (clerk: Clerk) => { return { status: 'error', message: `${method} needs an identifier.` }; } - return signInWithIdentifier(clerk, signIn, method, identifierValue); + return signInWithIdentifier(clerk, signIn, method, identifierValue, methods); }, }); }; From 1e9f26d25d5073ba1dc27968940053abe3c38559 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 24 Sep 2026 13:13:25 -0400 Subject: [PATCH 4/8] feat(clerk-js): show the page's sign-in form instead of a modal when no saved password is chosen Co-Authored-By: Claude Opus 5.5 --- .../modules/webmcp/__tests__/webmcp.test.ts | 25 +++++++++++-------- .../clerk-js/src/core/modules/webmcp/index.ts | 24 +++++++++--------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index 019a7928de3..92f924fee0e 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -54,8 +54,7 @@ const createClerk = () => { return Promise.resolve(); }), signOut: vi.fn(), - openSignIn: vi.fn(), - buildSignInUrl: vi.fn(() => 'https://accounts.acme.com/sign-in'), + redirectToSignIn: vi.fn(), addListener: vi.fn((callback: typeof listener) => { listener = callback; callback?.({ session: clerk.session }); @@ -249,27 +248,31 @@ describe('registerWebMcpTools', () => { }); }); - it('opens the sign-in form when no saved password is chosen', async () => { + it('shows the sign-in form when no saved password is chosen', async () => { Object.defineProperty(navigator, 'credentials', { value: { get: vi.fn().mockResolvedValue(null) }, configurable: true, }); + const signInForm = document.createElement('div'); + signInForm.className = 'cl-signIn-root'; + signInForm.append(document.createElement('form')); + signInForm.scrollIntoView = vi.fn(); + document.body.appendChild(signInForm); await register(); await expect(call('clerk_sign_in', { method: 'password', identifier: 'jane@acme.com' })).resolves.toMatchObject({ status: 'needs_user_action', - signInForm: 'opened', otherMethods: ['passkey', 'oauth_google', 'email_code', 'email_link'], }); - expect(setup.clerk.openSignIn).toHaveBeenCalledWith({ initialValues: { emailAddress: 'jane@acme.com' } }); + expect(signInForm.scrollIntoView).toHaveBeenCalled(); + expect(setup.clerk.redirectToSignIn).not.toHaveBeenCalled(); - setup.clerk.openSignIn.mockImplementation(() => { - throw new Error('Clerk was not loaded with Ui components'); - }); - await expect(call('clerk_sign_in', { method: 'password' })).resolves.toMatchObject({ - status: 'needs_user_action', - signInUrl: 'https://accounts.acme.com/sign-in', + signInForm.replaceChildren(); + await expect(call('clerk_sign_in', { method: 'password', identifier: 'jane@acme.com' })).resolves.toMatchObject({ + status: 'redirecting', }); + expect(setup.clerk.redirectToSignIn).toHaveBeenCalledWith({ initialValues: { emailAddress: 'jane@acme.com' } }); + signInForm.remove(); }); it('suggests other methods when no passkey is used', async () => { diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index 63d41569013..d5f69736ced 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -141,16 +141,6 @@ const getInitialValues = (identifier?: string): SignInInitialValues | undefined return identifier.startsWith('+') ? { phoneNumber: identifier } : { username: identifier }; }; -const openSignInForm = (clerk: Clerk, identifier?: string): ToolResult => { - const initialValues = getInitialValues(identifier); - try { - clerk.openSignIn({ initialValues }); - return { signInForm: 'opened' }; - } catch { - return { signInUrl: clerk.buildSignInUrl({ initialValues }) }; - } -}; - const signInWithSavedPassword = async ( clerk: Clerk, signIn: SignInResource, @@ -162,11 +152,21 @@ const signInWithSavedPassword = async ( .catch(() => null)) as SavedPassword | null | undefined; if (!credential?.password) { + const signInForm = document.querySelector('.cl-signIn-root:not(:empty)'); + if (!signInForm) { + await clerk.redirectToSignIn({ initialValues: getInitialValues(identifier) }); + return { + status: 'redirecting', + message: + 'No saved password was chosen. Opening the sign-in page, where the person needs to type their password.', + next: 'clerk_get_auth_state', + }; + } + signInForm.scrollIntoView({ block: 'center' }); return { status: 'needs_user_action', message: - 'No saved password was chosen. The person needs to type their password into the sign-in form, or try another method.', - ...openSignInForm(clerk, identifier), + 'No saved password was chosen. The person needs to type their password into the sign-in form on this page, or try another method.', otherMethods, }; } From 57830e1ff70656c5f3376eba28f19f13138e9bf6 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 24 Sep 2026 14:21:44 -0400 Subject: [PATCH 5/8] feat(clerk-js): register every WebMCP tool once instead of swapping them on sign-in Co-Authored-By: Claude Opus 5.5 --- .../modules/webmcp/__tests__/webmcp.test.ts | 41 ++++++---------- .../clerk-js/src/core/modules/webmcp/index.ts | 47 +++++++------------ 2 files changed, 32 insertions(+), 56 deletions(-) diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index 92f924fee0e..6f82d1f7a37 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -16,16 +16,14 @@ const createModelContext = () => { const tools = new Map(); return { tools, - registerTool: vi.fn((tool: Tool, options?: { signal?: AbortSignal }) => { + registerTool: vi.fn((tool: Tool) => { tools.set(tool.name, tool); - options?.signal?.addEventListener('abort', () => tools.delete(tool.name)); return Promise.resolve(); }), }; }; const createClerk = () => { - let listener: ((resources: { session: unknown }) => void) | undefined; const signIn = { status: null as string | null, createdSessionId: null as string | null, @@ -55,11 +53,6 @@ const createClerk = () => { }), signOut: vi.fn(), redirectToSignIn: vi.fn(), - addListener: vi.fn((callback: typeof listener) => { - listener = callback; - callback?.({ session: clerk.session }); - return () => {}; - }), __internal_getOption: vi.fn(() => undefined), __internal_environment: { displayConfig: { applicationName: 'Acme', signInUrl: 'https://accounts.acme.com/sign-in' }, @@ -81,7 +74,6 @@ const createClerk = () => { const signInAs = (user: unknown) => { clerk.session = { id: 'sess_1' }; clerk.user = user; - listener?.({ session: clerk.session }); }; return { clerk, signIn, update, signInAs }; @@ -120,13 +112,19 @@ describe('registerWebMcpTools', () => { registerWebMcpTools(setup.clerk as unknown as Clerk); - expect(setup.clerk.addListener).not.toHaveBeenCalled(); + expect(modelContext.registerTool).not.toHaveBeenCalled(); }); - it('registers the signed-out tools with the sign-in methods the instance accepts', async () => { + it('registers every tool once, with the sign-in methods the instance accepts', async () => { await register(); - expect([...modelContext.tools.keys()]).toEqual(['clerk_get_auth_state', 'clerk_sign_in', 'clerk_submit_code']); + expect([...modelContext.tools.keys()]).toEqual([ + 'clerk_get_auth_state', + 'clerk_sign_in', + 'clerk_submit_code', + 'clerk_switch_organization', + 'clerk_sign_out', + ]); expect(modelContext.tools.get('clerk_sign_in')?.inputSchema?.properties.method?.enum).toEqual([ 'passkey', 'oauth_google', @@ -140,19 +138,6 @@ describe('registerWebMcpTools', () => { }); }); - it('swaps to the signed-in tools once a session exists', async () => { - await register(); - - setup.signInAs({ organizationMemberships: [] }); - await flush(); - - expect([...modelContext.tools.keys()]).toEqual([ - 'clerk_get_auth_state', - 'clerk_switch_organization', - 'clerk_sign_out', - ]); - }); - it('signs in with an email code', async () => { const { signIn, update } = setup; const factor = { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'j***@acme.com' }; @@ -341,10 +326,14 @@ describe('registerWebMcpTools', () => { it('only switches to organizations the user belongs to', async () => { await register(); + await expect(call('clerk_switch_organization', { organization: 'acme' })).resolves.toEqual({ + status: 'error', + message: 'Not signed in. Call clerk_sign_in first.', + }); + setup.signInAs({ organizationMemberships: [{ organization: { id: 'org_1', slug: 'acme', name: 'Acme' }, role: 'org:admin' }], }); - await flush(); await expect(call('clerk_switch_organization', { organization: 'globex' })).resolves.toMatchObject({ status: 'error', diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index d5f69736ced..d8996ff0a12 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -19,7 +19,7 @@ type WebMcpTool = { }; type ModelContext = { - registerTool: (tool: WebMcpTool, options?: { signal?: AbortSignal }) => Promise | void; + registerTool: (tool: WebMcpTool) => Promise | void; }; type SavedPassword = Credential & { password?: string }; @@ -337,7 +337,11 @@ const getSwitchOrganizationTool = (clerk: Clerk) => required: ['organization'], }, execute: async ({ organization }) => { - const membership = clerk.user?.organizationMemberships.find( + if (!clerk.user) { + return { status: 'error', message: 'Not signed in. Call clerk_sign_in first.' }; + } + + const membership = clerk.user.organizationMemberships.find( ({ organization: org }) => org.id === organization || org.slug === organization, ); if (!membership) { @@ -365,33 +369,16 @@ export const registerWebMcpTools = (clerk: Clerk): void => { return; } - const register = (tools: WebMcpTool[], signal?: AbortSignal) => { - tools.forEach(tool => { - void Promise.resolve() - .then(() => modelContext.registerTool(tool, { signal })) - .catch(() => null); - }); - }; - - register([getAuthStateTool(clerk)]); - - let signedIn: boolean | undefined; - let controller: AbortController | undefined; - clerk.addListener(({ session }) => { - if (signedIn === Boolean(session)) { - return; - } - - signedIn = Boolean(session); - controller?.abort(); - controller = new AbortController(); - - const organizationsEnabled = clerk.__internal_environment?.organizationSettings.enabled; - register( - signedIn - ? [...(organizationsEnabled ? [getSwitchOrganizationTool(clerk)] : []), getSignOutTool(clerk)] - : [getSignInTool(clerk), getSubmitCodeTool(clerk)], - controller.signal, - ); + const organizationsEnabled = clerk.__internal_environment?.organizationSettings.enabled; + [ + getAuthStateTool(clerk), + getSignInTool(clerk), + getSubmitCodeTool(clerk), + ...(organizationsEnabled ? [getSwitchOrganizationTool(clerk)] : []), + getSignOutTool(clerk), + ].forEach(tool => { + void Promise.resolve() + .then(() => modelContext.registerTool(tool)) + .catch(() => null); }); }; From 0f9a9d700f0504e4f2f11ef4e04a85b92b4fab4f Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 30 Sep 2026 10:45:47 -0400 Subject: [PATCH 6/8] feat(clerk-js): dispatch a clerk:webmcp event for each WebMCP tool call Apps can listen on window and send agent activity to their own analytics. The event carries the tool name, result status and sign-in strategy, never the identifier or code. Co-Authored-By: Claude Opus 5.5 --- .changeset/experimental-webmcp-tools.md | 2 +- .../modules/webmcp/__tests__/webmcp.test.ts | 19 +++++++++++++++++++ .../clerk-js/src/core/modules/webmcp/index.ts | 12 +++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.changeset/experimental-webmcp-tools.md b/.changeset/experimental-webmcp-tools.md index 18574cd0dac..b73b4b92701 100644 --- a/.changeset/experimental-webmcp-tools.md +++ b/.changeset/experimental-webmcp-tools.md @@ -3,4 +3,4 @@ '@clerk/shared': minor --- -Add an experimental `webmcp` option (`experimental: { webmcp: true }`). In browsers that support [WebMCP](https://webmachinelearning.github.io/webmcp/), Clerk registers tools that let browser agents check auth state, sign in, submit verification codes, switch organizations and sign out. The tools never take a password or return a token; password and passkey sign-in go through the browser's own prompts. +Add an experimental `webmcp` option (`experimental: { webmcp: true }`). In browsers that support [WebMCP](https://webmachinelearning.github.io/webmcp/), Clerk registers tools that let browser agents check auth state, sign in, submit verification codes, switch organizations and sign out, without ever taking a password or returning a token. Each tool call dispatches a `clerk:webmcp` event on `window` with the tool name, result status and sign-in strategy, so you can track agent activity in your own analytics. diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index 6f82d1f7a37..f42f21d7fa7 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -159,6 +159,25 @@ describe('registerWebMcpTools', () => { expect(setup.clerk.setActive).toHaveBeenCalledWith({ session: 'sess_1' }); }); + it('dispatches a clerk:webmcp event for each tool call', async () => { + const { signIn, update } = setup; + const factor = { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'j***@acme.com' }; + signIn.create.mockImplementation(update({ status: 'needs_first_factor', supportedFirstFactors: [factor] })); + const events: unknown[] = []; + const listener = (event: Event) => events.push((event as CustomEvent).detail); + window.addEventListener('clerk:webmcp', listener); + await register(); + + await call('clerk_get_auth_state'); + await call('clerk_sign_in', { method: 'email_code', identifier: 'jane@acme.com' }); + window.removeEventListener('clerk:webmcp', listener); + + expect(events).toEqual([ + { tool: 'clerk_get_auth_state', status: 'signed_out' }, + { tool: 'clerk_sign_in', status: 'needs_code', strategy: 'email_code' }, + ]); + }); + it('asks for an authenticator app code when the account has MFA', async () => { const { signIn, update } = setup; signIn.status = 'needs_first_factor'; diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index d8996ff0a12..50b8ffbbeb9 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -42,9 +42,19 @@ const toError = (error: unknown): ToolResult => { }; }; +const getStatus = (result: ToolResult) => + typeof result.status === 'string' ? result.status : result.signedIn ? 'signed_in' : 'signed_out'; + const defineTool = (tool: WebMcpTool): WebMcpTool => ({ ...tool, - execute: input => tool.execute(input ?? {}).catch(toError), + execute: async input => { + const result = await tool.execute(input ?? {}).catch(toError); + const strategy = typeof input?.method === 'string' ? { strategy: input.method } : {}; + window.dispatchEvent( + new CustomEvent('clerk:webmcp', { detail: { tool: tool.name, status: getStatus(result), ...strategy } }), + ); + return result; + }, }); const getAppName = (clerk: Clerk) => clerk.__internal_environment?.displayConfig.applicationName || 'this app'; From 9e9ce7b9adf8a0a3eb6f48d3e8b59bdd5e6367b7 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Mon, 5 Oct 2026 12:24:13 -0400 Subject: [PATCH 7/8] chore(clerk-js): size bundlewatch limits to the WebMCP delta on current main Co-Authored-By: Claude Opus 5.5 --- packages/clerk-js/bundlewatch.config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 2165f1a5c21..70f73dbf573 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,9 +1,9 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "556KB" }, + { "path": "./dist/clerk.js", "maxSize": "557KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "83KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "127.5KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "325.25KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "324.25KB" }, { "path": "./dist/clerk.native.js", "maxSize": "82KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, From d5f1de4fcdbfe44a4265cd77f41ec015a45c6720 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Mon, 5 Oct 2026 14:56:00 -0400 Subject: [PATCH 8/8] fix(clerk-js): accept backup codes in clerk_submit_code Co-Authored-By: Claude Opus 5.5 --- .../src/core/modules/webmcp/__tests__/webmcp.test.ts | 12 ++++++++++++ packages/clerk-js/src/core/modules/webmcp/index.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts index f42f21d7fa7..6b92761c835 100644 --- a/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -202,6 +202,18 @@ describe('registerWebMcpTools', () => { expect(signIn.attemptSecondFactor).toHaveBeenCalledWith({ strategy: 'totp', code: '123456' }); }); + it('accepts a backup code instead of an authenticator app code', async () => { + const { signIn, update } = setup; + signIn.status = 'needs_second_factor'; + signIn.supportedSecondFactors = [{ strategy: 'totp' }, { strategy: 'backup_code' }]; + signIn.secondFactorVerification.strategy = 'totp'; + signIn.attemptSecondFactor.mockImplementation(update({ status: 'complete', createdSessionId: 'sess_1' })); + await register(); + + await expect(call('clerk_submit_code', { code: 'a1b2c3d4' })).resolves.toEqual({ status: 'signed_in' }); + expect(signIn.attemptSecondFactor).toHaveBeenCalledWith({ strategy: 'backup_code', code: 'a1b2c3d4' }); + }); + it('sends a code when the sign-in needs a new-device check', async () => { const { signIn, update } = setup; const factor = { strategy: 'email_code', emailAddressId: 'idn_1', safeIdentifier: 'j***@acme.com' }; diff --git a/packages/clerk-js/src/core/modules/webmcp/index.ts b/packages/clerk-js/src/core/modules/webmcp/index.ts index 50b8ffbbeb9..fea5918d7c9 100644 --- a/packages/clerk-js/src/core/modules/webmcp/index.ts +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -321,7 +321,11 @@ const getSubmitCodeTool = (clerk: Clerk) => } if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust') { - const strategy = signIn.secondFactorVerification.strategy ?? getSecondFactor(signIn)?.strategy; + const isBackupCode = + !/^\d{6}$/.test(code) && signIn.supportedSecondFactors?.some(factor => factor.strategy === 'backup_code'); + const strategy = isBackupCode + ? 'backup_code' + : (signIn.secondFactorVerification.strategy ?? getSecondFactor(signIn)?.strategy); if ( strategy === 'totp' || strategy === 'phone_code' ||