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
321 lines • 14.2 kB
JavaScript
/**
* BC CRUD Service
*
* High-level service for Create, Read, Update, Delete operations on BC forms.
* Implements the complete LoadForm → Field Resolution → SaveValue flow with
* CompletedInteractions barriers and FormState management.
*
* Critical architecture:
* - Single-flight requests: wait for CompletedInteractions before next request
* - FormState lifecycle: FormToShow → LoadForm → buildIndices → ready
* - Field resolution: Use multi-index (Caption, ScopedCaption, SourceExpr, Name)
* - oldValue: Always from FormState.node.value.formatted
* - Dialog handling: Semantic button selection (yes/no/ok/cancel)
*/
import { FormStateService } from './form-state-service.js';
import { logger } from '../core/logger.js';
/**
* BC CRUD Service
*/
export class BCCrudService {
client;
formStateService;
/** Whether to enforce single-flight requests (v1: true for safety) */
singleFlightMode = true;
/** In-flight request tracker */
inflightRequest = null;
constructor(client, formStateService) {
this.client = client;
this.formStateService = formStateService || new FormStateService();
}
/**
* Single-flight wrapper: ensures only one request in flight at a time
*/
async withSingleFlight(operation) {
if (!this.singleFlightMode) {
return operation();
}
// Wait for any in-flight request to complete
if (this.inflightRequest) {
try {
await this.inflightRequest;
}
catch (error) {
// Ignore errors from previous requests
}
}
// Execute this request
const promise = operation();
this.inflightRequest = promise;
try {
const result = await promise;
return result;
}
finally {
// Clear in-flight marker
if (this.inflightRequest === promise) {
this.inflightRequest = null;
}
}
}
/**
* Load form metadata and build field indices
*
* CRITICAL: Must be called after FormToShow before field interactions!
*
* @param formId - The form ID to load
* @param options - Load options
* @param openFormHandlers - Optional: handlers from OpenForm (contains FormToShow with control tree)
*/
async loadForm(formId, options, openFormHandlers) {
const opts = {
waitForReady: true,
retry: true,
timeoutMs: 10000,
...options
};
return this.withSingleFlight(async () => {
logger.info(`[BCCrudService] Loading form metadata for ${formId}...`);
// Step 1: Invoke LoadForm and collect handlers
const { immediateHandlers, asyncHandlers } = await this.invokeLoadFormWithListener(formId, opts.timeoutMs);
// Step 2: Process FormToShow handler if present
this.processFormShowHandler(formId, openFormHandlers || immediateHandlers);
// Step 3: Apply all handler changes to FormState
this.applyHandlerChanges(formId, [...immediateHandlers, ...asyncHandlers]);
// Step 4: Build indices and validate readiness
if (opts.waitForReady) {
await this.finalizeFormState(formId, options, opts);
}
});
}
/** Invoke LoadForm and collect both immediate and async handlers */
async invokeLoadFormWithListener(formId, timeoutMs) {
const asyncHandlers = [];
const unsubscribe = this.client.onHandlers((handlers) => {
asyncHandlers.push(...handlers);
});
try {
const immediateHandlers = await this.client.invoke({
interactionName: 'LoadForm',
namedParameters: { delayed: true, openForm: true, loadData: true },
formId,
timeoutMs
});
// Wait for CompletedInteractions if not in immediate response
const hasCompleted = immediateHandlers.find((h) => {
const handler = h;
return handler.handlerType === 'DN.CallbackResponseProperties';
});
if (!hasCompleted) {
await this.client.waitForHandlers((handlers) => {
const callbackHandler = handlers.find(h => h.handlerType === 'DN.CallbackResponseProperties');
return { matched: !!callbackHandler, data: handlers };
}, { timeoutMs });
}
// Wait for async handlers to arrive
await new Promise(resolve => setTimeout(resolve, 300));
return { immediateHandlers, asyncHandlers };
}
finally {
unsubscribe();
}
}
/** Process FormToShow handler to initialize FormState */
processFormShowHandler(formId, handlers) {
const formShowHandler = handlers.find((h) => {
const handler = h;
if (handler.handlerType !== 'DN.LogicalClientEventRaisingHandler')
return false;
if (handler.parameters?.[0] !== 'FormToShow')
return false;
const formData = handler.parameters?.[1];
return formData?.ServerId === formId;
});
if (formShowHandler) {
const formData = formShowHandler.parameters?.[1];
logger.info(`[BCCrudService] Found FormToShow data with ${formData?.Children?.length || 0} top-level controls`);
// Pass as unknown for compatibility with FormStateService's internal FormToShowData type
this.formStateService.initFromFormToShow(formId, formShowHandler.parameters?.[1]);
logger.info(`[BCCrudService] FormState initialized from FormToShow`);
}
}
/** Apply LogicalClientChangeHandler changes to FormState */
applyHandlerChanges(formId, handlers) {
for (const h of handlers) {
const handler = h;
if (handler.handlerType === 'DN.LogicalClientChangeHandler') {
const handlerFormId = handler.parameters?.[0];
if (handlerFormId === formId) {
// Pass changes as never for compatibility with FormStateService's BcChange type
const changes = handler.parameters?.[1];
this.formStateService.applyChanges(formId, changes);
}
}
}
}
/** Build indices and validate form readiness, retrying if needed */
async finalizeFormState(formId, originalOptions, opts) {
this.formStateService.buildIndices(formId);
const state = this.formStateService.getFormState(formId);
if (!state || !state.ready) {
if (opts.retry) {
logger.warn(`[BCCrudService] FormState not ready after first LoadForm, retrying...`);
return this.loadForm(formId, { ...originalOptions, retry: false, timeoutMs: opts.timeoutMs * 2 });
}
else {
throw new Error(`FormState for ${formId} is incomplete after LoadForm`);
}
}
logger.info(`[BCCrudService] Form ${formId} loaded and indexed with ${state.pathIndex.size} controls`);
}
/**
* Save field value using field name/caption resolution
*
* Automatically resolves field name to control path and retrieves oldValue from FormState.
*/
async saveField(formId, fieldKey, newValue, options) {
const opts = {
timeoutMs: 5000,
...options
};
return this.withSingleFlight(async () => {
logger.info(`[BCCrudService] Saving field "${fieldKey}" = "${newValue}" on form ${formId}...`);
// Resolve field
const resolution = this.formStateService.resolveField(formId, fieldKey);
if (!resolution) {
throw new Error(`Field "${fieldKey}" not found in form ${formId}`);
}
const { controlPath, node, ambiguous, candidates } = resolution;
if (ambiguous && candidates) {
logger.warn(`[BCCrudService] Ambiguous field "${fieldKey}" resolved to ${controlPath}. ` +
`Candidates: ${candidates.map(c => c.path).join(', ')}`);
}
// Get oldValue
const oldValue = opts.oldValueOverride !== undefined
? opts.oldValueOverride
: (node.value?.formatted || node.value?.raw?.toString() || '');
logger.info(`[BCCrudService] Resolved "${fieldKey}" → ${controlPath} (oldValue: "${oldValue}")`);
// Determine next field to activate
const nextFieldPath = opts.nextFieldPath || controlPath;
// Send SaveValue + ActivateControl in single request
await this.client.invoke({
interactionName: 'SaveValue',
namedParameters: { oldValue, newValue },
controlPath,
formId,
timeoutMs: opts.timeoutMs
});
// Immediately send ActivateControl (same invoke)
await this.client.invoke({
interactionName: 'ActivateControl',
namedParameters: { key: null },
controlPath: nextFieldPath,
formId,
timeoutMs: opts.timeoutMs
});
// Wait for CompletedInteractions
await this.client.waitForHandlers((handlers) => {
const completed = handlers.find(h => h.handlerType === 'DN.CallbackResponseProperties');
return { matched: !!completed, data: handlers };
}, { timeoutMs: opts.timeoutMs });
logger.info(`[BCCrudService] Field "${fieldKey}" saved successfully`);
const rawHandlers = await this.client.waitForHandlers((h) => ({ matched: true, data: h }), { timeoutMs: 100 }).catch(() => []);
// Cast handlers to typed array for property access
const handlers = rawHandlers;
for (const handler of handlers) {
if (handler.handlerType === 'DN.LogicalClientChangeHandler' &&
handler.parameters?.[0] === formId) {
// Cast changes parameter to expected type (it contains BC change objects)
this.formStateService.applyChanges(formId, handler.parameters?.[1]);
}
}
});
}
/**
* Invoke a system action (New, Delete, etc.)
*/
async invokeSystemAction(formId, systemAction, controlPath, options) {
const timeoutMs = options?.timeoutMs || 5000;
return this.withSingleFlight(async () => {
logger.info(`[BCCrudService] Invoking systemAction ${systemAction} on ${formId}...`);
await this.client.invoke({
interactionName: 'InvokeAction',
namedParameters: {
systemAction,
key: options?.key || null,
repeaterControlTarget: null
},
controlPath,
formId,
timeoutMs
});
// Wait for CompletedInteractions
const handlers = await this.client.waitForHandlers((handlers) => {
const completed = handlers.find(h => h.handlerType === 'DN.CallbackResponseProperties');
return { matched: !!completed, data: handlers };
}, { timeoutMs });
logger.info(`[BCCrudService] SystemAction ${systemAction} completed`);
return handlers;
});
}
/**
* Handle dialog confirmation by semantic button selection
*/
async confirmDialog(dialogFormId, intent, options) {
const timeoutMs = options?.timeoutMs || 5000;
return this.withSingleFlight(async () => {
logger.info(`[BCCrudService] Confirming dialog ${dialogFormId} with intent: ${intent}...`);
// Load dialog form if not already loaded
const dialogState = this.formStateService.getFormState(dialogFormId);
if (!dialogState || !dialogState.ready) {
await this.loadForm(dialogFormId, { timeoutMs });
}
// Select button
const button = this.formStateService.selectDialogButton(dialogFormId, intent);
if (!button) {
throw new Error(`No button found for intent "${intent}" in dialog ${dialogFormId}`);
}
logger.info(`[BCCrudService] Selected button: "${button.caption}" at ${button.controlPath}`);
// Click button (systemAction 380 for dialog confirmation)
await this.invokeSystemAction(dialogFormId, 380, button.controlPath, { timeoutMs });
logger.info(`[BCCrudService] Dialog confirmed with "${button.caption}"`);
});
}
/**
* Close a form
*/
async closeForm(formId, options) {
const timeoutMs = options?.timeoutMs || 5000;
return this.withSingleFlight(async () => {
logger.info(`[BCCrudService] Closing form ${formId}...`);
await this.client.invoke({
interactionName: 'CloseForm',
namedParameters: {},
controlPath: 'server:',
formId,
timeoutMs
});
// Wait for confirmation
await this.client.waitForHandlers((handlers) => {
const completed = handlers.find(h => h.handlerType === 'DN.CallbackResponseProperties');
return { matched: !!completed, data: handlers };
}, { timeoutMs });
// Remove from FormState cache
this.formStateService.deleteFormState(formId);
logger.info(`[BCCrudService] Form ${formId} closed`);
});
}
/**
* Get FormStateService for advanced operations
*/
getFormStateService() {
return this.formStateService;
}
/**
* Get underlying client for advanced operations
*/
getClient() {
return this.client;
}
}
//# sourceMappingURL=bc-crud-service.js.map