/** * WebMCP tool factory for the Persona theme editor. * * `createThemeEditorTools(state)` returns transport-agnostic tool definitions * designed for Agent Experience: intent-level operations (set brand colors, * assign a color role, set roundness…) rather than a 1:1 mapping of the ~150 * editor fields. Two altitudes: high-level semantic tools plus a low-level * escape hatch (`set_theme_fields`): keep the catalog small without losing * coverage. Every mutation returns a compact summary + contrast warnings. */ import { createTheme } from '../../utils/theme'; import type { AgentWidgetConfig } from '../../types'; import type { PersonaTheme } from '../../types/theme'; import { generateColorScale, SHADE_KEYS } from '../color-utils'; import { ALL_ROLES, resolveRoleAssignment } from '../role-mappings'; import type { RoleAssignmentOptions, FieldDef } from '../types'; import { THEME_EDITOR_PRESETS, getThemeEditorPreset } from '../presets'; import { ALL_TABS, CONFIGURE_SUB_GROUPS } from '../sections'; import { toolResult, type WebMcpTool, type ThemeEditorLike, type EditTarget, type CreateThemeEditorToolsOptions, } from './types'; import { coerceColor, coerceFamily, coerceIntensity, coerceScheme, coerceRoundnessStyle, coerceRadius, coerceTypographyRef, ROLE_FAMILY_NAMES, FONT_FAMILY_REFS, FONT_SIZE_REFS, FONT_WEIGHT_REFS, LINE_HEIGHT_REFS, } from './coerce'; import { buildSummary, quickContrastWarnings, runContrastChecks, roleContrastPairKeys, RADIUS_PRESETS, roleKey, type ContrastWarning, type ContrastLevel, } from './summary'; // ─── Role lookup ──────────────────────────────────────────────── const ROLE_ALIASES: Record = {}; for (const role of ALL_ROLES) { const key = roleKey(role.roleId); ROLE_ALIASES[key] = role; // e.g. "user-messages" ROLE_ALIASES[role.roleId] = role; // e.g. "role-user-messages" } Object.assign(ROLE_ALIASES, { surface: ROLE_ALIASES['surfaces'], background: ROLE_ALIASES['surfaces'], backgrounds: ROLE_ALIASES['surfaces'], user: ROLE_ALIASES['user-messages'], 'user-message': ROLE_ALIASES['user-messages'], assistant: ROLE_ALIASES['assistant-messages'], 'assistant-message': ROLE_ALIASES['assistant-messages'], actions: ROLE_ALIASES['primary-actions'], buttons: ROLE_ALIASES['primary-actions'], composer: ROLE_ALIASES['input'], links: ROLE_ALIASES['links-focus'], focus: ROLE_ALIASES['links-focus'], border: ROLE_ALIASES['borders'], dividers: ROLE_ALIASES['borders'], scroll: ROLE_ALIASES['scroll-to-bottom'], }); function coerceRole(input: unknown): RoleAssignmentOptions { const key = String(input ?? '').trim().toLowerCase(); const role = ROLE_ALIASES[key]; if (!role) { const valid = ALL_ROLES.map((r) => roleKey(r.roleId)).join(', '); throw new Error(`Unknown role "${input}". Valid roles: ${valid}.`); } return role; } // ─── Field index (escape hatch) ───────────────────────────────── function buildFieldIndex(): Map { const index = new Map(); const addSections = (sections: { fields: FieldDef[] }[]) => { for (const section of sections) { for (const field of section.fields) { if (!index.has(field.id)) index.set(field.id, field); } } }; for (const tab of ALL_TABS) addSections(tab.sections); for (const group of CONFIGURE_SUB_GROUPS) addSections(group.sections); return index; } // ─── Config-tool path maps ────────────────────────────────────── const FEATURE_PATHS: Record = { voice: 'voiceRecognition.enabled', artifacts: 'features.artifacts.enabled', attachments: 'attachments.enabled', toolCalls: 'features.showToolCalls', reasoning: 'features.showReasoning', feedback: 'messageActions.enabled', }; const LAYOUT_PATHS: Record = { avatars: 'layout.messages.avatar.show', timestamps: 'layout.messages.timestamp.show', showHeader: 'layout.showHeader', messageStyle: 'layout.messages.layout', }; const LAUNCHER_POSITIONS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']; const MESSAGE_STYLES = ['bubble', 'flat', 'minimal']; const COPY_PATHS: Record = { title: 'copy.welcomeTitle', subtitle: 'copy.welcomeSubtitle', placeholder: 'copy.inputPlaceholder', sendLabel: 'copy.sendButtonLabel', }; // ─── Factory ──────────────────────────────────────────────────── export function createThemeEditorTools( state: ThemeEditorLike, options?: CreateThemeEditorToolsOptions ): WebMcpTool[] { let editTarget: EditTarget = options?.editTarget ?? 'both'; let fieldIndex: Map | null = null; const rec = (input: unknown): Record => input && typeof input === 'object' ? (input as Record) : {}; /** Expand a theme-relative path to light/dark writes per editTarget. */ const expandScoped = ( themeRelPath: string, value: unknown, target: EditTarget = editTarget ): Record => { const out: Record = {}; if (target === 'light' || target === 'both') out[`theme.${themeRelPath}`] = value; if (target === 'dark' || target === 'both') out[`darkTheme.${themeRelPath}`] = value; return out; }; /** Drop light/dark writes that fall outside the current editTarget. */ const filterByEditTarget = (writes: Record): Record => { if (editTarget === 'both') return writes; const out: Record = {}; for (const [k, v] of Object.entries(writes)) { if (editTarget === 'light' && k.startsWith('theme.')) out[k] = v; else if (editTarget === 'dark' && k.startsWith('darkTheme.')) out[k] = v; } return out; }; /** Which variant a color mutation should be contrast-checked against. */ const warnVariant = (): 'light' | 'dark' => (editTarget === 'dark' ? 'dark' : 'light'); const result = (applied: unknown, warnings: ContrastWarning[] = []) => toolResult({ ok: true, summary: buildSummary(state), warnings, applied }); // ── Tools ────────────────────────────────────────────────── const getThemeOverview: WebMcpTool = { name: 'get_theme_overview', title: 'Get current theme & what is editable', description: 'Read theme summary, presets, and editable levers. Call before editing.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { verbosity: { type: 'string', enum: ['summary', 'full'], description: "'full' includes the field-id index.", }, }, additionalProperties: false, }, execute(input) { const { verbosity } = rec(input); const payload: Record = { summary: buildSummary(state), availableRoles: ALL_ROLES.map((r) => ({ role: roleKey(r.roleId), helper: r.helper, })), availableFamilies: ROLE_FAMILY_NAMES, presets: THEME_EDITOR_PRESETS.map((p) => ({ id: p.id, name: p.name, description: p.description, tags: p.tags ?? [], })), tools: [ { tool: 'set_brand_colors', hint: 'Recolor primary/secondary/accent and generate shade scales.' }, { tool: 'assign_color_role', hint: 'Recolor a widget region with family + intensity.' }, { tool: 'set_typography', hint: 'Set font family, size, weight, line height.' }, { tool: 'set_roundness', hint: 'Set corner roundness or granular radii.' }, { tool: 'set_color_scheme', hint: 'Set light/dark/auto and edit target.' }, { tool: 'apply_preset', hint: 'Apply a built-in preset.' }, { tool: 'configure_widget', hint: 'Toggle launcher position, features, and layout.' }, { tool: 'set_copy_and_suggestions', hint: 'Set welcome copy, placeholder, and suggestion chips.' }, { tool: 'set_theme_fields', hint: 'Set any field by id or dot-path.' }, { tool: 'check_contrast', hint: 'Audit WCAG contrast.' }, { tool: 'manage_session', hint: 'Undo, redo, reset, or export the theme.' }, ], }; if (verbosity === 'full') { fieldIndex ??= buildFieldIndex(); payload.fieldIndex = Array.from(fieldIndex.values()).map((f) => ({ id: f.id, path: f.path, type: f.type, label: f.label, options: f.options?.map((o) => o.value), })); } return toolResult(payload); }, }; const setBrandColors: WebMcpTool = { name: 'set_brand_colors', title: 'Set brand colors', description: 'Set primary, secondary, and/or accent colors. Generates shade scales and accepts hex, rgb/rgba, or CSS names.', inputSchema: { type: 'object', properties: { primary: { type: 'string', description: 'Hex, rgb/rgba, or CSS name.' }, secondary: { type: 'string', description: 'Hex, rgb/rgba, or CSS name.' }, accent: { type: 'string', description: 'Hex, rgb/rgba, or CSS name.' }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const families: Array<'primary' | 'secondary' | 'accent'> = ['primary', 'secondary', 'accent']; const writes: Record = {}; const applied: Record = {}; for (const family of families) { if (args[family] === undefined) continue; const base = coerceColor(args[family]); applied[family] = base; const scale = generateColorScale(base); for (const shade of SHADE_KEYS) { const value = scale[shade]; if (value === undefined) continue; Object.assign(writes, expandScoped(`palette.colors.${family}.${shade}`, value)); } } if (Object.keys(applied).length === 0) { throw new Error('Provide at least one of: primary, secondary, accent.'); } state.setBatch(writes); const warnings = quickContrastWarnings( state, ['primary-button', 'user-message'], warnVariant() ); return result(applied, warnings); }, }; const assignColorRole: WebMcpTool = { name: 'assign_color_role', title: 'Assign a color family to an interface role', description: 'Recolor a widget region by role, palette family, and intensity.', inputSchema: { type: 'object', properties: { role: { type: 'string', description: 'Role, e.g. header or user-messages.' }, family: { type: 'string', enum: ROLE_FAMILY_NAMES }, intensity: { type: 'string', enum: ['solid', 'soft'], description: 'Default: solid.' }, }, required: ['role', 'family'], additionalProperties: false, }, execute(input) { const args = rec(input); const role = coerceRole(args.role); const family = coerceFamily(args.family, true); const intensity = coerceIntensity(args.intensity); const writes = filterByEditTarget(resolveRoleAssignment(family, intensity, role)); const tokensWritten = Object.keys(writes).length; state.setBatch(writes); const warnings = quickContrastWarnings(state, roleContrastPairKeys(role), warnVariant()); return result( { role: roleKey(role.roleId), family, intensity, tokensWritten }, warnings ); }, }; const setTypography: WebMcpTool = { name: 'set_typography', title: 'Set typography', description: 'Set font family, size, weight, and line height.', inputSchema: { type: 'object', properties: { fontFamily: { type: 'string' }, fontSize: { type: 'string' }, fontWeight: { type: ['string', 'number'] }, lineHeight: { type: ['string', 'number'] }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const writes: Record = {}; const applied: Record = {}; const apply = ( key: 'fontFamily' | 'fontSize' | 'fontWeight' | 'lineHeight', refs: Record ) => { if (args[key] === undefined) return; const ref = coerceTypographyRef(args[key], refs, key); applied[key] = ref.split('.').pop() ?? ref; Object.assign(writes, expandScoped(`semantic.typography.${key}`, ref)); }; apply('fontFamily', FONT_FAMILY_REFS); apply('fontSize', FONT_SIZE_REFS); apply('fontWeight', FONT_WEIGHT_REFS); apply('lineHeight', LINE_HEIGHT_REFS); if (Object.keys(applied).length === 0) { throw new Error('Provide at least one of: fontFamily, fontSize, fontWeight, lineHeight.'); } state.setBatch(writes); return result(applied); }, }; const setRoundness: WebMcpTool = { name: 'set_roundness', title: 'Set corner roundness', description: 'Set roundness by style or granular radius values.', inputSchema: { type: 'object', properties: { style: { type: 'string', enum: ['sharp', 'default', 'rounded', 'pill'] }, radius: { type: 'object', description: 'Granular px or CSS length overrides.', properties: { sm: { type: ['string', 'number'] }, md: { type: ['string', 'number'] }, lg: { type: ['string', 'number'] }, xl: { type: ['string', 'number'] }, full: { type: ['string', 'number'] }, }, additionalProperties: false, }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const writes: Record = {}; const applied: Record = {}; if (args.style !== undefined) { const style = coerceRoundnessStyle(args.style); applied.style = style; for (const [key, value] of Object.entries(RADIUS_PRESETS[style])) { Object.assign(writes, expandScoped(`palette.radius.${key}`, value)); } } if (args.radius !== undefined) { const radius = rec(args.radius); const overrides: Record = {}; for (const key of ['sm', 'md', 'lg', 'xl', 'full']) { if (radius[key] === undefined) continue; const value = coerceRadius(radius[key]); overrides[key] = value; Object.assign(writes, expandScoped(`palette.radius.${key}`, value)); } applied.radius = overrides; } if (Object.keys(writes).length === 0) { throw new Error('Provide `style` (sharp|default|rounded|pill) or a `radius` object.'); } state.setBatch(writes); return result(applied); }, }; const setColorScheme: WebMcpTool = { name: 'set_color_scheme', title: 'Set color scheme', description: 'Set light/dark/auto color scheme and optional edit target.', inputSchema: { type: 'object', properties: { scheme: { type: 'string', enum: ['light', 'dark', 'auto'] }, editTarget: { type: 'string', enum: ['light', 'dark', 'both'] }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const applied: Record = {}; if (args.scheme !== undefined) { const scheme = coerceScheme(args.scheme); state.set('colorScheme', scheme); applied.scheme = scheme; } if (args.editTarget !== undefined) { const t = String(args.editTarget).trim().toLowerCase(); if (t !== 'light' && t !== 'dark' && t !== 'both') { throw new Error(`Unknown editTarget "${args.editTarget}". Valid: light, dark, both.`); } editTarget = t; applied.editTarget = t; } if (Object.keys(applied).length === 0) { throw new Error('Provide `scheme` and/or `editTarget`.'); } return toolResult({ ok: true, summary: buildSummary(state), warnings: [], applied, editTarget }); }, }; const applyPreset: WebMcpTool = { name: 'apply_preset', title: 'Apply a built-in theme preset', description: 'Apply a complete built-in preset, replacing theme tokens. Call get_theme_overview to list preset ids.', inputSchema: { type: 'object', properties: { presetId: { type: 'string' } }, required: ['presetId'], additionalProperties: false, }, execute(input) { const { presetId } = rec(input); const preset = getThemeEditorPreset(String(presetId ?? '')); if (!preset) { const valid = THEME_EDITOR_PRESETS.map((p) => p.id).join(', '); throw new Error(`Unknown preset "${presetId}". Valid presets: ${valid}.`); } const theme = createTheme(preset.theme, { validate: false }); const config: AgentWidgetConfig = { ...state.getConfig() }; if (preset.darkTheme) { config.darkTheme = createTheme(preset.darkTheme, { validate: false }) as PersonaTheme; } if (preset.toolCall) config.toolCall = preset.toolCall; state.setFullConfig(config, theme); const warnings = quickContrastWarnings(state, ['body', 'assistant-message'], 'light'); return result({ appliedPreset: { id: preset.id, name: preset.name } }, warnings); }, }; const configureWidget: WebMcpTool = { name: 'configure_widget', title: 'Configure launcher, features, and layout', description: 'Toggle launcher position, feature flags, and message layout.', inputSchema: { type: 'object', properties: { launcherPosition: { type: 'string', enum: LAUNCHER_POSITIONS }, features: { type: 'object', properties: { voice: { type: 'boolean' }, artifacts: { type: 'boolean' }, attachments: { type: 'boolean' }, toolCalls: { type: 'boolean' }, reasoning: { type: 'boolean' }, feedback: { type: 'boolean' }, }, additionalProperties: false, }, layout: { type: 'object', properties: { avatars: { type: 'boolean' }, timestamps: { type: 'boolean' }, showHeader: { type: 'boolean' }, messageStyle: { type: 'string', enum: MESSAGE_STYLES }, }, additionalProperties: false, }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const writes: Record = {}; const applied: Record = {}; if (args.launcherPosition !== undefined) { const pos = String(args.launcherPosition); if (!LAUNCHER_POSITIONS.includes(pos)) { throw new Error(`Unknown launcherPosition "${pos}". Valid: ${LAUNCHER_POSITIONS.join(', ')}.`); } writes['launcher.position'] = pos; applied.launcherPosition = pos; } const features = rec(args.features); for (const [key, path] of Object.entries(FEATURE_PATHS)) { if (features[key] === undefined) continue; writes[path] = Boolean(features[key]); (applied.features ??= {} as Record); (applied.features as Record)[key] = Boolean(features[key]); } const layout = rec(args.layout); for (const [key, path] of Object.entries(LAYOUT_PATHS)) { if (layout[key] === undefined) continue; if (key === 'messageStyle') { const style = String(layout[key]); if (!MESSAGE_STYLES.includes(style)) { throw new Error(`Unknown messageStyle "${style}". Valid: ${MESSAGE_STYLES.join(', ')}.`); } writes[path] = style; (applied.layout ??= {} as Record); (applied.layout as Record)[key] = style; } else { writes[path] = Boolean(layout[key]); (applied.layout ??= {} as Record); (applied.layout as Record)[key] = Boolean(layout[key]); } } if (Object.keys(writes).length === 0) { throw new Error('Provide launcherPosition, features, and/or layout.'); } state.setBatch(writes); return result(applied); }, }; const setCopyAndSuggestions: WebMcpTool = { name: 'set_copy_and_suggestions', title: 'Set welcome copy and suggestion chips', description: 'Set welcome copy, input placeholder, send label, and suggestion chips.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, subtitle: { type: 'string' }, placeholder: { type: 'string' }, sendLabel: { type: 'string' }, suggestions: { type: 'array', items: { type: 'string' } }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const writes: Record = {}; const applied: Record = {}; for (const [key, path] of Object.entries(COPY_PATHS)) { if (args[key] === undefined) continue; writes[path] = String(args[key]); applied[key] = String(args[key]); } if (args.suggestions !== undefined) { if (!Array.isArray(args.suggestions)) { throw new Error('`suggestions` must be an array of strings.'); } const chips = args.suggestions.filter((s): s is string => typeof s === 'string'); writes['suggestionChips'] = chips; applied.suggestions = chips; } if (Object.keys(writes).length === 0) { throw new Error('Provide at least one of: title, subtitle, placeholder, sendLabel, suggestions.'); } state.setBatch(writes); return result(applied); }, }; const setThemeFields: WebMcpTool = { name: 'set_theme_fields', title: 'Set theme fields by id or path (advanced)', description: 'Advanced escape hatch: set fields by id or raw dot-path. Values are validated when metadata exists.', inputSchema: { type: 'object', properties: { updates: { type: 'array', items: { type: 'object', properties: { field: { type: 'string', description: 'Field id or path.' }, value: { type: ['string', 'number', 'boolean'] }, }, required: ['field', 'value'], additionalProperties: false, }, }, }, required: ['updates'], additionalProperties: false, }, execute(input) { const { updates } = rec(input); if (!Array.isArray(updates) || updates.length === 0) { throw new Error('`updates` must be a non-empty array of { field, value }.'); } fieldIndex ??= buildFieldIndex(); const writes: Record = {}; const report: Array<{ field: string; resolvedPath?: string | string[]; ok: boolean; error?: string; }> = []; for (const raw of updates) { const entry = rec(raw); const fieldKey = String(entry.field ?? ''); try { const def = fieldIndex.get(fieldKey); const path = def ? def.path : fieldKey; if (!def && !/^(theme|darkTheme)\.|\./.test(path)) { // A bare token with no field def and no dotted path is ambiguous. throw new Error( `Unknown field "${fieldKey}". Pass a known field id or a dot-path (e.g. theme.palette.radius.md).` ); } const value = coerceFieldValue(def, entry.value); if (def && path.startsWith('theme.')) { // Field ids resolve to light-theme paths; honor the active edit // target so dark-only / both edits are reachable by id (not only by // raw darkTheme.* dot-path). const scoped = expandScoped(path.slice('theme.'.length), value); Object.assign(writes, scoped); report.push({ field: fieldKey, resolvedPath: Object.keys(scoped), ok: true }); } else { writes[path] = value; report.push({ field: fieldKey, resolvedPath: path, ok: true }); } } catch (err) { report.push({ field: fieldKey, ok: false, error: (err as Error).message }); } } const okWrites = report.filter((r) => r.ok); if (Object.keys(writes).length > 0) state.setBatch(writes); return toolResult({ ok: okWrites.length > 0, summary: buildSummary(state), warnings: [], applied: { updates: report }, }); }, }; const checkContrast: WebMcpTool = { name: 'check_contrast', title: 'Check accessibility contrast', description: 'Run WCAG checks over key text/background pairs.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { level: { type: 'string', enum: ['AA', 'AAA'], description: 'Default: AA.' }, variant: { type: 'string', enum: ['light', 'dark', 'both'], description: 'Default: both.' }, }, additionalProperties: false, }, execute(input) { const args = rec(input); const level = (args.level === 'AAA' ? 'AAA' : 'AA') as ContrastLevel; const variant = args.variant === 'light' || args.variant === 'dark' ? args.variant : 'both'; const report = runContrastChecks(state, level, variant); return toolResult({ level: report.level, passing: report.checks.length - report.failures.length, total: report.checks.length, checks: report.checks, failures: report.failures, }); }, }; const manageSession: WebMcpTool = { name: 'manage_session', title: 'Undo, redo, reset, or export the theme', description: 'Undo, redo, reset defaults, or export the theme snapshot.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['undo', 'redo', 'reset', 'export'] } }, required: ['action'], additionalProperties: false, }, execute(input) { const { action } = rec(input); switch (action) { case 'undo': state.undo(); return result({ action: 'undo' }); case 'redo': state.redo(); return result({ action: 'redo' }); case 'reset': state.resetToDefaults(); return result({ action: 'reset' }); case 'export': return toolResult({ ok: true, snapshot: state.exportSnapshot() }); default: throw new Error(`Unknown action "${action}". Valid: undo, redo, reset, export.`); } }, }; return [ getThemeOverview, setBrandColors, assignColorRole, setTypography, setRoundness, setColorScheme, applyPreset, configureWidget, setCopyAndSuggestions, setThemeFields, checkContrast, manageSession, ]; } // ─── Field value validation (escape hatch) ────────────────────── function coerceFieldValue(def: FieldDef | undefined, value: unknown): unknown { if (!def) return value; switch (def.type) { case 'color': return def.parseValue ? def.parseValue(coerceColor(value)) : coerceColor(value); case 'toggle': return typeof value === 'boolean' ? value : value === 'true' || value === 1; case 'slider': { const num = Number(value); if (!Number.isFinite(num)) throw new Error(`"${def.id}" expects a number.`); if (def.slider) { const { min, max } = def.slider; if (num < min || num > max) { throw new Error(`"${def.id}" must be between ${min} and ${max}.`); } } return def.parseValue ? def.parseValue(num) : num; } case 'select': { const str = String(value); if (def.options && !def.options.some((o) => o.value === str)) { throw new Error( `"${def.id}" must be one of: ${def.options.map((o) => o.value).join(', ')}.` ); } return def.parseValue ? def.parseValue(str) : str; } default: return def.parseValue ? def.parseValue(value) : value; } }