diff --git a/.changeset/experimental-webmcp-tools.md b/.changeset/experimental-webmcp-tools.md new file mode 100644 index 00000000000..b73b4b92701 --- /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, 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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index ce853a560ba..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": "126KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, + { "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" }, 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..6b92761c835 --- /dev/null +++ b/packages/clerk-js/src/core/modules/webmcp/__tests__/webmcp.test.ts @@ -0,0 +1,377 @@ +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) => { + tools.set(tool.name, tool); + return Promise.resolve(); + }), + }; +}; + +const createClerk = () => { + 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(), + redirectToSignIn: vi.fn(), + __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; + }; + + 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(modelContext.registerTool).not.toHaveBeenCalled(); + }); + + 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', + 'clerk_switch_organization', + 'clerk_sign_out', + ]); + 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('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('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'; + 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('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' }; + 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('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', + otherMethods: ['passkey', 'oauth_google', 'email_code', 'email_link'], + }); + expect(signInForm.scrollIntoView).toHaveBeenCalled(); + expect(setup.clerk.redirectToSignIn).not.toHaveBeenCalled(); + + 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 () => { + 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("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(); + + 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(); + 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 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..fea5918d7c9 --- /dev/null +++ b/packages/clerk-js/src/core/modules/webmcp/index.ts @@ -0,0 +1,398 @@ +import type { + ClerkAPIError, + OAuthStrategy, + SignInInitialValues, + 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) => 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', + code: (error as { code?: string } | undefined)?.code, + message: error instanceof Error ? error.message : String(error), + }; +}; + +const getStatus = (result: ToolResult) => + typeof result.status === 'string' ? result.status : result.signedIn ? 'signed_in' : 'signed_out'; + +const defineTool = (tool: WebMcpTool): WebMcpTool => ({ + ...tool, + 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'; + +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 getInitialValues = (identifier?: string): SignInInitialValues | undefined => { + if (!identifier) { + return undefined; + } + if (identifier.includes('@')) { + return { emailAddress: identifier }; + } + return identifier.startsWith('+') ? { phoneNumber: identifier } : { username: identifier }; +}; + +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; + + 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 on this page, or try another method.', + otherMethods, + }; + } + + 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, + methods: 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' }; + } + + 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) => + 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. 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: { + 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(', ')}.` }; + } + + const otherMethods = methods.filter(other => other !== method); + const identifierValue = typeof identifier === 'string' && identifier ? identifier : undefined; + + if (method === 'passkey') { + 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, identifierValue, otherMethods); + } + + if (method.startsWith('oauth_') || method === 'enterprise_sso') { + await signIn.authenticateWithRedirect({ + strategy: method as OAuthStrategy | 'enterprise_sso', + identifier: identifierValue, + redirectUrl: getSignInCallbackUrl(clerk, '/sso-callback'), + redirectUrlComplete: window.location.href, + }); + return { status: 'redirecting', next: 'clerk_get_auth_state' }; + } + + if (!identifierValue) { + return { status: 'error', message: `${method} needs an identifier.` }; + } + + return signInWithIdentifier(clerk, signIn, method, identifierValue, methods); + }, + }); +}; + +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 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' || + 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 }) => { + 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) { + 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 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); + }); +}; 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.