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
JavaScript
/**
* 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