UNPKG

bc-webclient-mcp

Version:

Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server

601 lines 22.4 kB
/** * FormState Service * * Manages BC form metadata, control tree parsing, field indexing, * and field/button resolution for CRUD operations. * * Critical requirements: * - LoadForm MUST be called after FormToShow before field interactions * - Field resolution uses multi-index (Caption, ScopedCaption, SourceExpr, Name) * - oldValue for SaveValue comes from FormState.node.value.formatted * - Dialog buttons are resolved semantically by intent (yes/no/ok/cancel) */ import { normalizeKey, parseScopedKey, isSourceExprKey } from '../types/form-state.js'; /** * Semantic button caption sets for dialog resolution */ const BUTTON_SYNONYMS = { yes: new Set([ 'yes', 'ok', 'accept', 'confirm', 'continue', 'proceed', 'apply', 'save', 'ja', 'oui', 'sí', 'si', 'はい', '확인', 'sim', 'da' ]), no: new Set([ 'no', 'cancel', 'abort', 'dismiss', 'reject', 'nej', 'non', 'annuller', 'avbryt', 'キャンセル', '취소', 'não', 'нет' ]), ok: new Set([ 'ok', 'okay', 'accept', 'confirm', 'done', 'ja', 'oui', 'vale', 'хорошо', 'تأكيد' ]), cancel: new Set([ 'cancel', 'abort', 'dismiss', 'close', 'annuller', 'avbryt', 'キャンセル', '취소', 'cancelar', 'отмена' ]), close: new Set([ 'close', 'exit', 'dismiss', 'leave', 'luk', 'fermer', 'cerrar', 'закрыть', 'إغلاق' ]), accept: new Set([ 'accept', 'agree', 'yes', 'ok', 'confirm', 'accepter', 'aceptar', 'принять', 'قبول' ]), reject: new Set([ 'reject', 'decline', 'no', 'refuse', 'refuser', 'rechazar', 'отклонить', 'رفض' ]) }; /** * FormState Service - manages form metadata and field resolution */ export class FormStateService { formStates = new Map(); config; constructor(config) { this.config = { maxSize: 50, ttl: 30 * 60 * 1000, // 30 minutes autoLoad: true, ...config }; } /** * Create empty FormState for a new form */ createFormState(formId) { const state = { formId, pathIndex: new Map(), fieldIndex: { byCaption: new Map(), byCaptionScoped: new Map(), bySourceExpr: new Map(), byName: new Map(), duplicates: new Map() }, ready: false, lastUpdated: new Date() }; this.formStates.set(formId, state); this.evictOldEntries(); return state; } /** * Get FormState for a form */ getFormState(formId) { return this.formStates.get(formId); } /** * Get or create FormState */ getOrCreateFormState(formId) { let state = this.formStates.get(formId); if (!state) { state = this.createFormState(formId); } return state; } /** * Delete FormState (e.g., on FormClosed) */ deleteFormState(formId) { return this.formStates.delete(formId); } /** * Initialize FormState from FormToShow data (from OpenForm response) * * FormToShow contains the complete form structure in parameters[1]: * - ServerId: form ID * - Children: array of top-level controls * - Caption, DesignName, etc. * * This must be called BEFORE applyChanges() to establish the control tree. */ initFromFormToShow(formId, formToShowData) { const state = this.getOrCreateFormState(formId); // FormToShow has Children array at the root level const children = formToShowData?.Children || formToShowData?.children; if (!children || !Array.isArray(children) || children.length === 0) { return; } // Build control tree from Children using existing parseControl logic // Each child in FormToShow.Children is a top-level control state.root = { path: 'server:', children: [] }; children.forEach((control, index) => { const childPath = `server:c[${index}]`; const node = this.parseControl(state, control, childPath); if (node) { state.root.children.push(node); } }); // Add root to pathIndex state.pathIndex.set('server:', state.root); state.lastUpdated = new Date(); } /** * Clear all FormStates (e.g., on sessionKey rotation) */ clearAll() { this.formStates.clear(); } /** * Apply changes from DN.LogicalClientChangeHandler to FormState * * This is the critical function that parses LoadForm responses * and builds the control tree. */ applyChanges(formId, changes) { const state = this.getOrCreateFormState(formId); if (!changes || typeof changes !== 'object') { return; } // Handle array of changes if (Array.isArray(changes)) { for (const change of changes) { this.applySingleChange(state, change); } } else { this.applySingleChange(state, changes); } state.lastUpdated = new Date(); } /** * Apply a single change object to FormState */ applySingleChange(state, change) { if (!change || typeof change !== 'object') return; const changeType = change.t || change.type; switch (changeType) { case 'PropertyChanges': // Property updates on existing controls this.applyPropertyChanges(state, change); break; case 'ControlChange': case 'ControlAdded': // New control or control modification this.applyControlChange(state, change); break; case 'DataRefreshChange': // Data updates (repeater rows, field values) this.applyDataRefresh(state, change); break; case 'FullUpdate': case 'InitialState': // Complete form structure (initial LoadForm) this.applyFullUpdate(state, change); break; default: // Unknown change type - try to extract controls anyway const controls = change.Controls || change.controls; if (controls) { this.parseControls(state, controls, 'server:'); } break; } } /** * Apply property changes to existing controls */ applyPropertyChanges(state, change) { const controlRef = change.ControlReference || change.controlReference; if (!controlRef) return; const path = this.resolveControlPath(controlRef); const node = state.pathIndex.get(path); if (!node) return; // Update properties - use change itself as fallback if Properties not defined const props = change.Properties || change.properties || change; const propsRecord = props; if (propsRecord.Caption !== undefined) node.caption = String(propsRecord.Caption); if (propsRecord.Name !== undefined) node.name = String(propsRecord.Name); if (propsRecord.Editable !== undefined) node.editable = Boolean(propsRecord.Editable); if (propsRecord.Visible !== undefined) node.visible = Boolean(propsRecord.Visible); if (propsRecord.Value !== undefined) { node.value = node.value || {}; node.value.raw = propsRecord.Value; node.value.formatted = propsRecord.FormattedValue ? String(propsRecord.FormattedValue) : String(propsRecord.Value); } } /** * Apply control change (add/modify control) */ applyControlChange(state, change) { const control = change.Control || change; const parentPath = change.ParentPath || 'server:'; this.parseControl(state, control, parentPath); } /** * Apply data refresh (field values, repeater data) */ applyDataRefresh(state, change) { const controlRef = change.ControlReference || change.controlReference; if (!controlRef) return; const path = this.resolveControlPath(controlRef); const node = state.pathIndex.get(path); if (!node) return; // Update row data for repeaters if (change.RowChanges || change.rowChanges) { // Store repeater data (for future list operations) node.metadata = node.metadata || {}; node.metadata.rowChanges = change.RowChanges || change.rowChanges; } // Update field value if (change.Value !== undefined) { node.value = node.value || {}; node.value.raw = change.Value; node.value.formatted = change.FormattedValue || String(change.Value); } } /** * Apply full form update (initial structure) */ applyFullUpdate(state, change) { const controls = change.Controls || change.controls || change.RootControls || change.rootControls; if (controls) { state.root = this.parseControls(state, controls, 'server:'); } } /** * Parse array of controls into control tree */ parseControls(state, controls, parentPath) { const rootNode = { path: parentPath, children: [] }; if (!Array.isArray(controls)) return rootNode; controls.forEach((control, index) => { const childPath = `${parentPath}c[${index}]`; const node = this.parseControl(state, control, childPath); if (node) { rootNode.children.push(node); } }); return rootNode; } /** * Parse single control into ControlNode */ parseControl(state, control, path) { if (!control || typeof control !== 'object') return null; const node = { path, caption: control.Caption || control.caption, name: control.Name || control.name, sourceExpr: control.SourceExpr || control.sourceExpr || control.SourceExpression, kind: control.Kind || control.kind || control.Type || control.type, editable: control.Editable !== false, // Default true visible: control.Visible !== false, // Default true isPrimary: control.IsPrimary || control.isPrimary || control.IsDefault || control.isDefault, children: [], metadata: {} }; // Parse value if present if (control.Value !== undefined) { node.value = { raw: control.Value, formatted: control.FormattedValue || String(control.Value) }; } // Store additional metadata if (control.Metadata) { node.metadata = { ...control.Metadata }; } // Parse children recursively const children = control.Controls || control.controls || control.Children || control.children; if (Array.isArray(children) && children.length > 0) { children.forEach((child, index) => { const childPath = `${path}/c[${index}]`; const childNode = this.parseControl(state, child, childPath); if (childNode) { node.children.push(childNode); } }); } // Add to path index state.pathIndex.set(path, node); return node; } /** * Resolve control path from ControlReference object */ resolveControlPath(controlRef) { if (typeof controlRef === 'string') return controlRef; if (controlRef.controlPath) return controlRef.controlPath; if (controlRef.ControlPath) return controlRef.ControlPath; return 'server:'; } /** * Build field indices after LoadForm completes * * MUST be called after all DN.LogicalClientChangeHandler messages * for the LoadForm request have been processed. */ buildIndices(formId) { const state = this.formStates.get(formId); if (!state) return; // Clear existing indices state.fieldIndex.byCaption.clear(); state.fieldIndex.byCaptionScoped.clear(); state.fieldIndex.bySourceExpr.clear(); state.fieldIndex.byName.clear(); state.fieldIndex.duplicates.clear(); // Build indices via DFS if (state.root) { this.indexNode(state, state.root, []); } state.ready = true; } /** * Index a single control node (recursive DFS) */ indexNode(state, node, scopeStack) { // Index by caption if (node.caption) { const normCaption = normalizeKey(node.caption); this.addToIndex(state.fieldIndex.byCaption, state.fieldIndex.duplicates, normCaption, node.path); // Index scoped caption if (scopeStack.length > 0) { const scopedKey = normalizeKey([...scopeStack, node.caption].join('>')); state.fieldIndex.byCaptionScoped.set(scopedKey, node.path); } } // Index by sourceExpr if (node.sourceExpr) { const normExpr = normalizeKey(node.sourceExpr); state.fieldIndex.bySourceExpr.set(normExpr, node.path); } // Index by name if (node.name) { const normName = normalizeKey(node.name); state.fieldIndex.byName.set(normName, node.path); } // Extend scope for groups/fasttabs const isContainer = node.kind && ['Group', 'FastTab', 'Part', 'Container'].includes(node.kind); const nextScope = isContainer && node.caption ? [...scopeStack, node.caption] : scopeStack; // Recurse to children for (const child of node.children) { this.indexNode(state, child, nextScope); } } /** * Add to index with duplicate tracking */ addToIndex(index, duplicates, key, path) { if (index.has(key)) { // Duplicate detected const existing = index.get(key); if (!duplicates.has(key)) { duplicates.set(key, [existing]); } duplicates.get(key).push(path); } else { index.set(key, path); } } /** Try to resolve by SourceExpr (e.g., [Customer.Email]) */ tryResolveBySourceExpr(state, userKey) { const srcExprCheck = isSourceExprKey(userKey); if (!srcExprCheck.isSourceExpr || !srcExprCheck.expr) return null; const path = state.fieldIndex.bySourceExpr.get(normalizeKey(srcExprCheck.expr)); if (!path) return null; const node = state.pathIndex.get(path); return node ? { controlPath: path, node, ambiguous: false } : null; } /** Try to resolve by scoped caption (e.g., "General > Name") */ tryResolveByScopedCaption(state, parts) { const scopedKey = normalizeKey(parts.join('>')); const path = state.fieldIndex.byCaptionScoped.get(scopedKey); if (!path) return null; const node = state.pathIndex.get(path); return node ? { controlPath: path, node, ambiguous: false } : null; } /** Try to resolve by unscoped caption with duplicate handling */ tryResolveByCaption(state, normKey, opts) { const duplicatePaths = state.fieldIndex.duplicates.get(normKey); // Handle duplicates with disambiguation if (duplicatePaths && duplicatePaths.length > 1) { const candidates = duplicatePaths .map(p => state.pathIndex.get(p)) .filter((n) => n !== undefined); const filtered = this.filterCandidates(candidates, opts); if (filtered.length > 0) { return { controlPath: filtered[0].path, node: filtered[0], ambiguous: true, candidates }; } } // Single match or no duplicates const path = state.fieldIndex.byCaption.get(normKey); if (!path) return null; const node = state.pathIndex.get(path); return node ? { controlPath: path, node, ambiguous: false } : null; } /** Try to resolve by control name */ tryResolveByName(state, normKey) { const path = state.fieldIndex.byName.get(normKey); if (!path) return null; const node = state.pathIndex.get(path); return node ? { controlPath: path, node, ambiguous: false } : null; } /** * Resolve field name/caption to control path * * Supports: * - Unscoped caption: "Email" * - Scoped caption: "General > Name" or "Address/City" * - SourceExpr override: "[Customer.Email]" * - Control name: field name from metadata */ resolveField(formId, userKey, options) { const state = this.formStates.get(formId); if (!state || !state.ready) return null; const opts = { preferEditable: true, preferVisible: true, requireScoped: false, ...options }; // 1. Try SourceExpr override const sourceExprResult = this.tryResolveBySourceExpr(state, userKey); if (sourceExprResult) return sourceExprResult; if (isSourceExprKey(userKey).isSourceExpr) return null; // SourceExpr format but not found // 2. Parse and try scoped caption const { scoped, parts } = parseScopedKey(userKey); if (scoped) { const scopedResult = this.tryResolveByScopedCaption(state, parts); if (scopedResult) return scopedResult; if (opts.requireScoped) return null; // User required scoped, don't fall back } // 3. Try unscoped caption (with duplicate handling) const normKey = normalizeKey(parts[parts.length - 1]); const captionResult = this.tryResolveByCaption(state, normKey, opts); if (captionResult) return captionResult; // 4. Fallback to control name return this.tryResolveByName(state, normKey); } /** * Filter candidates based on heuristics */ filterCandidates(candidates, opts) { let filtered = [...candidates]; if (opts.preferEditable) { const editable = filtered.filter(c => c.editable); if (editable.length > 0) filtered = editable; } if (opts.preferVisible) { const visible = filtered.filter(c => c.visible); if (visible.length > 0) filtered = visible; } return filtered; } /** * Select a dialog button by semantic intent * * Used for confirmation dialogs (delete, save, etc.) */ selectDialogButton(formId, intent) { const state = this.formStates.get(formId); if (!state || !state.ready) { return null; } const synonymSet = BUTTON_SYNONYMS[intent]; if (!synonymSet) { throw new Error(`Unknown button intent: ${intent}`); } // Find all action buttons const buttons = []; for (const [path, node] of state.pathIndex) { if (node.kind === 'Action' && node.caption) { buttons.push({ path, caption: node.caption, isPrimary: node.isPrimary }); } } // Match against synonym set const matches = buttons.filter(b => synonymSet.has(normalizeKey(b.caption))); if (matches.length === 1) { return { controlPath: matches[0].path, caption: matches[0].caption, ambiguous: false }; } if (matches.length > 1) { // Prefer primary button const primary = matches.find(m => m.isPrimary); if (primary) { // TODO: Re-enable for debugging when not using stdio transport // console.warn( // `[FormStateService] Multiple "${intent}" buttons found, using primary: ${primary.caption}` // ); return { controlPath: primary.path, caption: primary.caption, ambiguous: true, candidates: matches }; } // Pick first const first = matches[0]; // TODO: Re-enable for debugging when not using stdio transport // console.warn( // `[FormStateService] Multiple "${intent}" buttons, using first: ${first.caption}. ` + // `Candidates: ${matches.map(m => m.caption).join(', ')}` // ); return { controlPath: first.path, caption: first.caption, ambiguous: true, candidates: matches }; } // No match - try primary button as fallback const primary = buttons.find(b => b.isPrimary); if (primary) { // TODO: Re-enable for debugging when not using stdio transport // console.warn( // `[FormStateService] No "${intent}" button found, using primary: ${primary.caption}` // ); return { controlPath: primary.path, caption: primary.caption, ambiguous: false }; } return null; } /** * Evict old FormState entries when cache size exceeds limit */ evictOldEntries() { if (this.formStates.size <= this.config.maxSize) return; // Sort by lastUpdated, oldest first const sorted = Array.from(this.formStates.entries()).sort((a, b) => a[1].lastUpdated.getTime() - b[1].lastUpdated.getTime()); // Remove oldest entries const toRemove = sorted.slice(0, sorted.length - this.config.maxSize); for (const [formId] of toRemove) { this.formStates.delete(formId); } } } //# sourceMappingURL=form-state-service.js.map