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
913 lines • 67.7 kB
JavaScript
/**
* Write Page Data MCP Tool
*
* Creates or updates records on a BC page by setting field values.
* Uses the real SaveValue protocol captured from BC traffic.
*
* Usage workflow:
* 1. Call get_page_metadata to open the page
* 2. Call execute_action with "Edit" (for updates) or "New" (for creates)
* 3. Call write_page_data with field values
*/
import { BaseMCPTool } from './base-tool.js';
import { ok, err, isOk } from '../core/result.js';
import { InputValidationError, ProtocolError } from '../core/errors.js';
import { ConnectionManager } from '../connection/connection-manager.js';
import { createToolLogger, logger as moduleLogger } from '../core/logger.js';
import { ControlParser } from '../parsers/control-parser.js';
import { PageContextCache } from '../services/page-context-cache.js';
import { createWorkflowIntegration } from '../services/workflow-integration.js';
import { isPropertyChange, isPropertyChanges } from '../types/bc-protocol-types.js';
import { isDataRefreshChangeType, isDataRowInsertedType, isPropertyChangesType, isPropertyChangeType, } from '../types/bc-type-discriminators.js';
/**
* Type guard to check if a handler is a LogicalClientChangeHandler
*/
function isLogicalClientChangeHandler(handler) {
return handler.handlerType === 'DN.LogicalClientChangeHandler';
}
/**
* Get the handlerType from any handler (standard or generic)
*/
function getHandlerType(handler) {
return handler.handlerType;
}
/**
* Get parameters from any handler (standard or generic)
*/
function getHandlerParams(handler) {
return handler.parameters;
}
/**
* MCP Tool: write_page_data
*
* Writes data to a BC page (sets field values on current record).
*
* Prerequisites:
* - Page must be open (call get_page_metadata first)
* - Record must be in edit mode (call execute_action with "Edit" or "New")
*/
export class WritePageDataTool extends BaseMCPTool {
connection;
bcConfig;
name = 'write_page_data';
description = 'Sets field values on the current Business Central record with immediate validation. Requires pageContextId from get_page_metadata. ' +
'Prerequisites: Record MUST be in edit mode (call execute_action with "Edit" for existing records or "New" for new records first). ' +
'fields: Provide as simple map {"FieldName": value} where keys are field names/captions (case-insensitive), ' +
'OR as array [{name: "FieldName", value: value, controlPath?: "path"}] for precise targeting. ' +
'Field references use field name or caption from get_page_metadata.fields. ' +
'SUBPAGE/LINE OPERATIONS (NEW): To write to document lines (Sales Orders, Purchase Orders, etc.), provide subpage parameter with line identifier. ' +
'subpage (optional): Subpage/repeater name (e.g., "SalesLines") for line item operations. ' +
'lineBookmark (optional): Bookmark of specific line to update (most reliable). ' +
'lineNo (optional): Line number to update (1-based, resolved to bookmark internally). ' +
'If neither lineBookmark nor lineNo provided, creates NEW line. Provide EITHER lineBookmark OR lineNo, not both. ' +
'stopOnError (default true): stops processing remaining fields on first validation error. ' +
'immediateValidation (default true): runs Business Central OnValidate triggers immediately and surfaces validation messages. ' +
'Returns: {updatedFields: [], failedFields: [{field, error, validationMessage}], saved: false}. ' +
'IMPORTANT: This call does NOT commit/post to the database. Field changes are held in memory. ' +
'Use execute_action("Save") or execute_action("Post") to persist changes to the database.';
inputSchema = {
type: 'object',
properties: {
pageContextId: {
type: 'string',
description: 'Required page context ID from get_page_metadata',
},
fields: {
oneOf: [
{
type: 'object',
description: 'Simple map: field name → value (e.g., {"Name": "Test", "Credit Limit (LCY)": 5000})',
additionalProperties: true,
},
{
type: 'array',
description: 'Advanced: array with controlPath support [{name, value, controlPath?}]',
items: {
type: 'object',
properties: {
name: { type: 'string' },
value: { type: ['string', 'number', 'boolean', 'null'], description: 'Field value (null to clear)' },
controlPath: { type: 'string' },
},
required: ['name', 'value'],
},
},
],
},
stopOnError: {
type: 'boolean',
description: 'Stop on first validation error (default: true)',
default: true,
},
immediateValidation: {
type: 'boolean',
description: 'Parse BC handlers for validation errors (default: true)',
default: true,
},
subpage: {
type: 'string',
description: 'Optional: Subpage/repeater name for line item operations (e.g., "SalesLines")',
},
lineBookmark: {
type: 'string',
description: 'Optional: Bookmark of specific line to update (most reliable method)',
},
lineNo: {
type: 'number',
description: 'Optional: Line number to update (1-based, resolved to bookmark internally)',
minimum: 1,
},
workflowId: {
type: 'string',
description: 'Optional workflow ID to track this operation as part of a multi-step business process. ' +
'When provided, tracks unsaved field changes and records operation result.',
},
},
required: ['pageContextId', 'fields'],
};
// Consent configuration - Write operation requiring user approval
requiresConsent = true;
sensitivityLevel = 'medium';
consentPrompt = 'Write field values to a Business Central record? This will modify data in your Business Central database.';
constructor(connection, bcConfig, auditLogger) {
super({ auditLogger });
this.connection = connection;
this.bcConfig = bcConfig;
}
/**
* Validates and extracts input with field normalization.
* NOTE: write-page-data-tool uses legacy validation due to complex field format normalization.
* Zod migration deferred pending refactoring.
*/
validateInput(input) {
const baseResult = super.validateInput(input);
if (!isOk(baseResult)) {
return baseResult;
}
const pageContextIdResult = this.getRequiredString(input, 'pageContextId');
if (!isOk(pageContextIdResult)) {
return pageContextIdResult;
}
const fieldsValue = input.fields;
if (!fieldsValue) {
return err(new InputValidationError('fields parameter is required', 'fields', ['Must provide fields']));
}
// Normalize fields to internal format: Record<string, {value, controlPath?}>
let fields;
if (typeof fieldsValue === 'object' && !Array.isArray(fieldsValue)) {
fields = {};
for (const [name, value] of Object.entries(fieldsValue)) {
// Check if value is already wrapped with {value, controlPath?} structure
if (typeof value === 'object' && value !== null && 'value' in value) {
// Already wrapped - use as-is
fields[name] = value;
}
else {
// Primitive or other format - wrap it
fields[name] = { value };
}
}
}
else {
return err(new InputValidationError('fields must be an object', 'fields', ['Expected object format']));
}
const stopOnErrorValue = input.stopOnError;
const stopOnError = typeof stopOnErrorValue === 'boolean' ? stopOnErrorValue : true;
const immediateValidationValue = input.immediateValidation;
const immediateValidation = typeof immediateValidationValue === 'boolean' ? immediateValidationValue : true;
// Extract and validate subpage/line parameters
const subpage = input.subpage;
const lineBookmark = input.lineBookmark;
const lineNo = input.lineNo;
// Validate: Cannot provide both lineBookmark AND lineNo
if (lineBookmark && lineNo) {
return err(new InputValidationError('Cannot provide both lineBookmark and lineNo - use one or the other', 'lineBookmark/lineNo', ['Provide EITHER lineBookmark OR lineNo, not both']));
}
// Validate: lineBookmark/lineNo require subpage parameter
if ((lineBookmark || lineNo) && !subpage) {
return err(new InputValidationError('lineBookmark or lineNo requires subpage parameter to be specified', 'subpage', ['Must provide subpage name when using lineBookmark or lineNo']));
}
return ok({
pageContextId: pageContextIdResult.value,
fields,
stopOnError,
immediateValidation,
subpage,
lineBookmark,
lineNo,
});
}
/**
* Builds a map of field names to metadata from cached LogicalForm.
* Uses ControlParser to extract all fields from the control tree.
*
* @param logicalForm - Cached LogicalForm from pageContext
* @returns Map of field name → FieldMetadata (case-insensitive keys)
*/
buildFieldMap(logicalForm) {
const parser = new ControlParser();
const controls = parser.walkControls(logicalForm);
const fields = parser.extractFields(controls);
const fieldMap = new Map();
for (const field of fields) {
// Add field by all possible names (case-insensitive)
const names = [
field.name,
field.caption,
field.controlId,
].filter((n) => !!n);
for (const name of names) {
const key = name.toLowerCase().trim();
if (!fieldMap.has(key)) {
fieldMap.set(key, field);
}
}
}
return fieldMap;
}
/**
* Validates that a field exists and is editable using cached metadata.
* Provides helpful error messages for common issues.
*
* @param fieldName - Field name to validate
* @param fieldMap - Map of available fields from buildFieldMap()
* @returns Result with field metadata or validation error
*/
validateFieldExists(fieldName, fieldMap) {
const key = fieldName.toLowerCase().trim();
const field = fieldMap.get(key);
if (!field) {
// Field doesn't exist - provide helpful error
const availableFields = Array.from(new Set(Array.from(fieldMap.values())
.map(f => f.caption || f.name)
.filter((n) => !!n))).slice(0, 10);
return err(new InputValidationError(`Field "${fieldName}" not found on page`, fieldName, [
`Field "${fieldName}" does not exist on this page.`,
`Available fields: ${availableFields.join(', ')}${fieldMap.size > 10 ? ', ...' : ''}`,
`Hint: Field names are case-insensitive. Check spelling and use caption or name.`
]));
}
// Check if field is visible
if (!field.visible) {
return err(new InputValidationError(`Field "${fieldName}" is not visible`, fieldName, [
`Field "${fieldName}" exists but is not visible on the page.`,
`Hidden fields cannot be edited.`
]));
}
// Check if field is enabled
if (!field.enabled) {
return err(new InputValidationError(`Field "${fieldName}" is disabled`, fieldName, [
`Field "${fieldName}" exists but is disabled.`,
`Disabled fields cannot be edited.`
]));
}
// Check if field is readonly
if (field.readonly) {
return err(new InputValidationError(`Field "${fieldName}" is read-only`, fieldName, [
`Field "${fieldName}" is marked as read-only.`,
`Read-only fields cannot be modified.`
]));
}
return ok(field);
}
/**
* Executes the tool to write page data.
* Uses legacy validation with field normalization.
*
* Sets field values on the current record using SaveValue interactions.
*/
async executeInternal(input) {
const inputObj = input;
const logger = createToolLogger('write_page_data', inputObj?.pageContextId);
// Validate and normalize input
const validatedInput = this.validateInput(input);
if (!isOk(validatedInput)) {
return validatedInput;
}
const validatedData = validatedInput.value;
const { pageContextId, fields, stopOnError, immediateValidation, subpage, lineBookmark, lineNo, workflowId } = validatedData;
// Create workflow integration if workflowId provided
const workflow = createWorkflowIntegration(workflowId);
const fieldNames = Object.keys(fields);
logger.info(`Writing ${fieldNames.length} fields using pageContext: "${pageContextId}"`);
logger.info(`Fields: ${fieldNames.join(', ')}`);
logger.info(`Options: stopOnError=${stopOnError}, immediateValidation=${immediateValidation}`);
if (subpage) {
logger.info(`Line operation: subpage="${subpage}", lineBookmark="${lineBookmark || 'N/A'}", lineNo=${lineNo || 'N/A'}`);
}
const manager = ConnectionManager.getInstance();
let connection;
let actualSessionId;
let pageId;
// Extract sessionId and pageId from pageContextId (format: sessionId:page:pageId:timestamp)
const contextParts = pageContextId.split(':');
if (contextParts.length < 3) {
return err(new ProtocolError(`Invalid pageContextId format: ${pageContextId}`, { pageContextId }));
}
const sessionId = contextParts[0];
pageId = contextParts[2];
// Try to reuse existing session from pageContextId
const existing = manager.getSession(sessionId);
if (existing) {
logger.info(`Reusing session from pageContext: ${sessionId}`);
connection = existing;
actualSessionId = sessionId;
// Check if the page context is still valid in memory
const connWithCtx = connection;
let pageContext = connWithCtx.pageContexts?.get(pageContextId);
// If not in memory, try restoring from persistent cache
if (!pageContext) {
logger.info(`Page context not in memory, checking persistent cache...`);
try {
const cache = PageContextCache.getInstance();
const cachedContext = await cache.load(pageContextId);
if (cachedContext) {
logger.info(`Restored pageContext from cache: ${pageContextId}`);
// Restore to memory
if (!connWithCtx.pageContexts) {
connWithCtx.pageContexts = new Map();
}
// Cast through unknown since CachedPageContext may have different optional fields
connWithCtx.pageContexts.set(pageContextId, cachedContext);
pageContext = cachedContext;
}
}
catch (error) {
logger.warn(`Failed to load from cache: ${error}`);
}
}
// If still not found, return error
if (!pageContext) {
logger.info(`Page context not found in memory or cache`);
return err(new ProtocolError(`Page context ${pageContextId} not found. Page may have been closed. Please call get_page_metadata again.`, { pageContextId }));
}
}
else {
return err(new ProtocolError(`Session ${sessionId} from pageContext not found. Please call get_page_metadata first.`, { pageContextId, sessionId }));
}
// Check if page is open
if (!connection.isPageOpen(pageId)) {
return err(new ProtocolError(`Page ${pageId} is not open in session ${actualSessionId}. Call get_page_metadata first to open the page.`, { pageId, fields: fieldNames, sessionId: actualSessionId }));
}
// Get formId for this page
const formId = connection.getOpenFormId(pageId);
if (!formId) {
return err(new ProtocolError(`No formId found for page ${pageId} in session ${actualSessionId}. Page may not be properly opened.`, { pageId, fields: fieldNames, sessionId: actualSessionId }));
}
logger.info(`Using formId: ${formId}`);
// OPTIMIZATION: Use cached LogicalForm for client-side field validation
// This follows the caching pattern: extract metadata once in get_page_metadata, reuse here
const pageContext = connection.pageContexts?.get(pageContextId);
let fieldMap = null;
let targetRowBookmark; // Bookmark for existing row modification in subpages
if (pageContext?.logicalForm && !subpage) {
// Only validate header fields if NOT in line mode
logger.info(`Using cached LogicalForm for client-side field validation`);
const headerFieldMap = this.buildFieldMap(pageContext.logicalForm);
fieldMap = headerFieldMap; // Store for later use in field-writing loop
logger.info(` Field map contains ${headerFieldMap.size} field entries`);
// Pre-validate all fields before making BC API calls
for (const fieldName of fieldNames) {
const validationResult = this.validateFieldExists(fieldName, headerFieldMap);
if (!isOk(validationResult)) {
logger.info(`Pre-validation failed for field "${fieldName}": ${validationResult.error.message}`);
return validationResult;
}
logger.info(`Pre-validated field "${fieldName}"`);
}
}
else if (!subpage) {
logger.info(`No cached LogicalForm available, skipping client-side validation`);
}
// LINE/SUBPAGE OPERATION: Handle line operations if subpage is provided
if (subpage) {
logger.info(`Handling line operation for subpage "${subpage}"`);
// Find repeater by subpage name (uses PageState if available)
const repeaterResult = await this.findRepeaterBySubpage(pageContextId, pageContext, subpage);
if (!isOk(repeaterResult)) {
return repeaterResult;
}
const repeater = repeaterResult.value;
logger.info(`Found repeater: ${repeater.caption || repeater.name} at ${repeater.controlPath}`);
// If lineBookmark or lineNo provided, we're updating an existing line
if (lineBookmark || lineNo) {
// Determine which bookmark to use
targetRowBookmark = lineBookmark;
// If lineNo provided, resolve to bookmark from PageState cache
if (lineNo && !lineBookmark) {
const cache = PageContextCache.getInstance();
const pageState = await cache.getPageState(pageContextId);
if (!pageState) {
return err(new InputValidationError(`lineNo parameter requires cached page state - call read_page_data first`, 'lineNo', [`Page state not found in cache for pageContextId: ${pageContextId}`]));
}
// Find the repeater in PageState by matching name/caption
let repeaterState;
for (const [, rs] of pageState.repeaters) {
// Match by caption (user-facing name like "SalesLines") or name
if (rs.caption?.toLowerCase().includes(subpage.toLowerCase()) ||
rs.name.toLowerCase().includes(subpage.toLowerCase())) {
repeaterState = rs;
break;
}
}
if (!repeaterState) {
return err(new InputValidationError(`Repeater "${subpage}" not found in cached page state`, 'subpage', [`Available repeaters: ${Array.from(pageState.repeaters.keys()).join(', ')}`]));
}
// Get bookmark from rowOrder array (1-indexed lineNo to 0-indexed array)
const rowIndex = lineNo - 1;
if (rowIndex < 0 || rowIndex >= repeaterState.rowOrder.length) {
return err(new InputValidationError(`lineNo ${lineNo} is out of range - only ${repeaterState.rowOrder.length} rows available`, 'lineNo', [`Valid range: 1 to ${repeaterState.rowOrder.length}`]));
}
targetRowBookmark = repeaterState.rowOrder[rowIndex];
logger.info(`Resolved lineNo ${lineNo} to bookmark: ${targetRowBookmark}`);
}
// PROTOCOL FIX: Instead of using SetCurrentRowAndRowsSelection, we pass the bookmark
// directly in SaveValue's 'key' parameter. This is more reliable and matches how
// BC web client handles cell edits in existing rows.
logger.info(`Updating existing line with bookmark: ${targetRowBookmark} (using SaveValue key)`);
}
else {
// Neither lineBookmark nor lineNo provided - writing to draft row (new line creation)
logger.info(`Writing to draft row in subpage "${subpage}"`);
// CORRECT APPROACH from decompiled BC code analysis:
// BC uses DraftLinePattern/MultipleNewLinesPattern to PRE-CREATE draft rows during LoadForm.
// Document subforms (Sales Lines, Purchase Lines) have 15+ draft rows at the end.
//
// PROTOCOL SEQUENCE:
// 1. Draft rows already exist from LoadForm (in DataRefreshChange)
// 2. User clicks into draft row OR we find first available draft row
// 3. SaveValue populates fields (marks row as Dirty | Draft)
// 4. AutoInsertPattern automatically commits when user tabs to next field
//
// See decompiled:
// - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/DraftLinePattern.cs
// - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/MultipleNewLinesPattern.cs
// - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/AutoInsertPattern.cs
//
// SIMPLIFIED APPROACH:
// Since draft rows exist and BC's web client uses SetCurrentRow with Delta to navigate,
// we can simply send SaveValue directly to the repeater. BC will:
// - Position to the next available draft row automatically
// - Apply the values and mark row as dirty
// - AutoInsertPattern commits when we move on
//
// No explicit row selection needed for NEW lines - BC handles positioning.
logger.info(`Draft rows should exist from LoadForm - proceeding directly with field writes`);
}
// Build column field map from repeater metadata for field validation
fieldMap = this.buildColumnFieldMap(repeater);
logger.info(`Built column field map with ${fieldMap.size} columns`);
}
// Set each field value using SaveValue interaction
const updatedFields = [];
const failedFields = [];
for (const [fieldName, fieldSpec] of Object.entries(fields)) {
let { value: fieldValue, controlPath } = fieldSpec;
// CRITICAL: Look up controlPath from fieldMap if not provided
// This is essential for cache updates to work properly
if (!controlPath && fieldMap) {
const lookupKey = fieldName.toLowerCase().trim();
const fieldMeta = fieldMap.get(lookupKey);
if (fieldMeta?.controlPath) {
controlPath = fieldMeta.controlPath;
logger.info(`Resolved controlPath for "${fieldName}": ${controlPath}`);
}
else {
// Debug: show what keys are available and check if key exists
const hasKey = fieldMap.has(lookupKey);
const fieldMetaDebug = fieldMap.get(lookupKey);
const availableKeys = Array.from(fieldMap.keys()).slice(0, 10).join(', ');
logger.error(`[CACHE DEBUG] controlPath lookup failed for "${fieldName}": hasKey=${hasKey}, fieldMeta exists=${!!fieldMetaDebug}, fieldMeta.controlPath=${fieldMetaDebug?.controlPath}`);
logger.warn(`Could not resolve controlPath for "${fieldName}" (key="${lookupKey}") from fieldMap. Available keys (first 10): ${availableKeys}`);
}
}
logger.info(`Setting field "${fieldName}" = "${fieldValue}"${controlPath ? ` (controlPath: ${controlPath})` : ''}...`);
const result = await this.setFieldValue(connection, formId, fieldName, fieldValue, pageContextId, controlPath, immediateValidation, targetRowBookmark // Pass bookmark for existing row modification
);
if (isOk(result)) {
updatedFields.push(fieldName);
logger.info(`Field "${fieldName}" updated successfully`);
}
else {
const errorMsg = result.error.message;
const errorWithCtx = result.error;
const validationMsg = errorWithCtx.context?.validationMessage;
failedFields.push({
field: fieldName,
error: errorMsg,
validationMessage: validationMsg,
});
logger.info(`Field "${fieldName}" failed: ${errorMsg}`);
// Stop on first error if stopOnError is true
if (stopOnError) {
logger.info(`Stopping on first error (stopOnError=true)`);
break;
}
}
}
// NOTE: needsRefresh flag is now set conditionally in setFieldValue() method
// Only set if PropertyChanges cache update fails - avoids broken LoadForm on Card pages
// Return result
if (failedFields.length === 0) {
// All fields updated successfully
// Track unsaved changes and record operation in workflow
if (workflow) {
workflow.trackUnsavedChanges(fields);
workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, { success: true, data: { updatedFields, fieldCount: updatedFields.length } });
}
return ok({
success: true,
pageContextId,
saved: false, // This tool never saves - caller must use execute_action("Save")
message: `Successfully updated ${updatedFields.length} field(s): ${updatedFields.join(', ')}`,
updatedFields,
});
}
else if (updatedFields.length > 0) {
// Partial success
// Track partial unsaved changes and record operation with errors in workflow
if (workflow) {
// Track only the fields that succeeded
const successfulFieldsObj = {};
for (const fieldName of updatedFields) {
successfulFieldsObj[fieldName] = fields[fieldName];
}
workflow.trackUnsavedChanges(successfulFieldsObj);
// Record operation with partial success
workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, {
success: false,
error: `Partially updated ${updatedFields.length} field(s). Failed: ${failedFields.map(f => f.field).join(', ')}`,
data: { updatedFields, failedFields }
});
// Record failed fields as errors
for (const failed of failedFields) {
workflow.recordError(`Field "${failed.field}": ${failed.error}${failed.validationMessage ? ` - ${failed.validationMessage}` : ''}`);
}
}
return ok({
success: false,
pageContextId,
saved: false, // This tool never saves
message: `Partially updated ${updatedFields.length} field(s). Failed: ${failedFields.map(f => f.field).join(', ')}`,
updatedFields,
failedFields, // Structured: [{ field, error, validationMessage? }]
});
}
else {
// Complete failure
// Record operation failure and errors in workflow
if (workflow) {
const errorMsg = `Failed to update any fields. Errors: ${failedFields.map(f => `${f.field}: ${f.error}`).join('; ')}`;
workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, { success: false, error: errorMsg, data: { failedFields } });
// Record each field failure as an error
for (const failed of failedFields) {
workflow.recordError(`Field "${failed.field}": ${failed.error}${failed.validationMessage ? ` - ${failed.validationMessage}` : ''}`);
}
}
return err(new ProtocolError(`Failed to update any fields. Errors: ${failedFields.map(f => `${f.field}: ${f.error}`).join('; ')}`, { pageId, formId, failedFields }));
}
}
/**
* Finds a repeater (subpage) by name using PageState (preferred) or LogicalForm (fallback).
* Searches by both caption and design name (case-insensitive).
*
* Phase 1: Uses PageState if available, falls back to LogicalForm
* Phase 2: PageState will be required
*/
async findRepeaterBySubpage(pageContextId, pageContext, subpageName) {
const logger = moduleLogger.child({ method: 'findRepeaterBySubpage' });
// Try PageState first (Phase 1: Dual-state approach)
try {
const cache = PageContextCache.getInstance();
const pageState = await cache.getPageState(pageContextId);
if (pageState) {
logger.info(`Using PageState for repeater lookup`);
// Search repeaters Map by name (case-insensitive)
const searchKey = subpageName.toLowerCase().trim();
let foundRepeater;
for (const [_key, repeater] of pageState.repeaters.entries()) {
if (repeater.caption?.toLowerCase().trim() === searchKey ||
repeater.name?.toLowerCase().trim() === searchKey) {
foundRepeater = repeater;
break;
}
}
if (foundRepeater) {
// Convert PageState RepeaterState to RepeaterMetadata for compatibility
const repeaterMeta = {
name: foundRepeater.name,
caption: foundRepeater.caption,
controlPath: foundRepeater.controlPath,
formId: foundRepeater.formId,
columns: Array.from(foundRepeater.columns.values()).map(col => ({
caption: col.caption,
designName: col.designName,
controlPath: col.controlPath,
index: col.index,
controlId: col.controlId,
visible: col.visible,
editable: col.editable,
columnBinderPath: col.columnBinderPath,
})),
};
logger.info(`Found repeater "${repeaterMeta.caption || repeaterMeta.name}" via PageState`);
logger.info(` - controlPath: ${repeaterMeta.controlPath}`);
logger.info(` - formId: ${repeaterMeta.formId || 'undefined'}`);
logger.info(` - columns.length: ${repeaterMeta.columns.length}`);
logger.info(` - totalRowCount: ${foundRepeater.totalRowCount || 'undefined'}`);
logger.info(` - loaded rows: ${foundRepeater.rows.size}`);
logger.info(` - pendingOperations: ${foundRepeater.pendingOperations}`);
logger.info(` - isDirty: ${foundRepeater.isDirty}`);
return ok(repeaterMeta);
}
// Not found in PageState - fall through to LogicalForm
logger.info(`Repeater "${subpageName}" not found in PageState, trying LogicalForm fallback`);
}
else {
logger.info(`No PageState available, using LogicalForm for repeater lookup`);
}
}
catch (error) {
logger.warn(`PageState lookup failed: ${error}, falling back to LogicalForm`);
}
// Fallback: Use LogicalForm (original implementation)
const logicalForm = pageContext?.logicalForm;
if (!logicalForm) {
return err(new ProtocolError(`No cached LogicalForm or PageState available for repeater lookup`, { subpageName }));
}
// Extract repeaters from logicalForm using ControlParser
const parser = new ControlParser();
const controls = parser.walkControls(logicalForm);
const repeaters = parser.extractRepeaters(controls);
// Search by name (case-insensitive)
const searchKey = subpageName.toLowerCase().trim();
const found = repeaters.find(r => r.caption?.toLowerCase().trim() === searchKey ||
r.name?.toLowerCase().trim() === searchKey);
if (!found) {
const availableNames = repeaters
.map(r => r.caption || r.name)
.filter(Boolean)
.join(', ');
return err(new InputValidationError(`Subpage "${subpageName}" not found on page`, 'subpage', [
`The subpage/repeater "${subpageName}" does not exist on this page.`,
`Available subpages: ${availableNames || 'none'}`,
]));
}
// DIAGNOSTIC: Log found repeater metadata
logger.info(`Found repeater "${found.caption || found.name}" via LogicalForm`);
logger.info(` - controlPath: ${found.controlPath}`);
logger.info(` - formId: ${found.formId || 'undefined'}`);
logger.info(` - columns.length: ${found.columns.length}`);
if (found.columns.length > 0) {
logger.info(` - First 3 columns: ${found.columns.slice(0, 3).map(c => c.caption || c.designName).join(', ')}`);
}
else {
logger.warn(` WARNING: Repeater has ZERO columns! This will cause "Cannot find controlPath" error.`);
}
return ok(found);
}
/**
* Builds a field map from repeater column metadata.
* Maps column captions and design names to column metadata.
*/
buildColumnFieldMap(repeater) {
const logger = moduleLogger.child({ method: 'buildColumnFieldMap' });
const map = new Map();
// DIAGNOSTIC: Log what we're building from
logger.info(`Building field map from ${repeater.columns.length} columns`);
if (repeater.columns.length === 0) {
logger.warn(` ⚠️ PROBLEM: No columns to build map from!`);
return map;
}
for (const column of repeater.columns) {
// Add by caption
if (column.caption) {
const key = column.caption.toLowerCase().trim();
map.set(key, column);
}
// Add by design name
if (column.designName) {
const key = column.designName.toLowerCase().trim();
map.set(key, column);
}
}
logger.info(` Built map with ${map.size} field name mappings`);
if (map.size > 0) {
const sampleKeys = Array.from(map.keys()).slice(0, 5);
logger.info(` Sample keys: ${sampleKeys.join(', ')}`);
}
return map;
}
/**
* Selects a line in a repeater using SetCurrentRowAndRowsSelection.
* This sets server-side focus to the target row before field updates.
*/
async selectLine(connection, formId, repeaterPath, bookmark) {
const logger = createToolLogger('write_page_data', formId);
logger.info(`Selecting line with bookmark "${bookmark}" in repeater ${repeaterPath}`);
try {
await connection.invoke({
interactionName: 'SetCurrentRowAndRowsSelection',
namedParameters: {
key: bookmark,
selectAll: false,
rowsToSelect: [bookmark],
unselectAll: true,
rowsToUnselect: [],
},
controlPath: repeaterPath,
formId,
callbackId: '', // Empty callback for synchronous operations
});
logger.info(`Successfully selected line`);
return ok(undefined);
}
catch (error) {
logger.error(`Failed to select line: ${error}`);
return err(new ProtocolError(`Failed to select line in subpage: ${error}`, { repeaterPath, bookmark, error }));
}
}
/**
* Extracts bookmark from DataRefreshChange event (for new line creation).
* BC sends this async event when a new line is inserted with the new bookmark.
*/
extractBookmarkFromDataRefresh(handlers) {
const logger = createToolLogger('write_page_data', 'bookmark-extraction');
// Look for LogicalClientChangeHandler with DataRefreshChange
for (const handler of handlers) {
if (isLogicalClientChangeHandler(handler)) {
const changes = handler.parameters?.[1];
if (Array.isArray(changes)) {
for (const change of changes) {
// BC27+ uses full type name 'DataRefreshChange' instead of shorthand 'drch'
if (isDataRefreshChangeType(change.t)) {
// Look for DataRowInserted with bookmark
const dataRefresh = change;
const rowChanges = dataRefresh.RowChanges || [];
for (const rowChange of rowChanges) {
// BC27+ uses full type name 'DataRowInserted' instead of shorthand 'drich'
if (isDataRowInsertedType(rowChange.t)) {
// DataRowInsertedChange has Row: ClientDataRow with Bookmark property
const rowData = rowChange.Row;
const bookmark = rowData?.Bookmark;
if (bookmark) {
logger.info(`Extracted bookmark from new line: ${bookmark}`);
return bookmark;
}
}
}
}
}
}
}
}
logger.warn(`No bookmark found in DataRefreshChange handlers`);
return undefined;
}
/**
* Sets a field value using the SaveValue interaction.
* Uses the real BC protocol captured from traffic.
* Optionally inspects handlers for validation errors.
*
* Supports null values for clearing fields (converted to empty string).
*/
async setFieldValue(connection, formId, fieldName, value, pageContextId, controlPath, immediateValidation = true, rowBookmark // Bookmark for existing row modification (null for header/new rows)
) {
// Handle null values (clear field)
let actualValue;
if (value === null || value === undefined) {
actualValue = ''; // Clear field by setting to empty string
}
else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
actualValue = value;
}
else {
return err(new InputValidationError(`Field '${fieldName}' value must be a string, number, boolean, or null`, fieldName, [`Expected string|number|boolean|null, got ${typeof value}`]));
}
// Build SaveValue interaction using real BC protocol
// This matches the protocol we captured and verified
const interaction = {
interactionName: 'SaveValue',
skipExtendingSessionLifetime: false,
namedParameters: JSON.stringify({
key: rowBookmark || null, // Use bookmark for existing row, null for header/new rows
newValue: actualValue,
alwaysCommitChange: true,
notifyBusy: 1,
telemetry: {
'Control name': fieldName,
'QueuedTime': new Date().toISOString(),
},
}),
callbackId: '', // Will be set by connection
controlPath: controlPath || undefined, // Use provided controlPath or let BC find it
formId,
};
// ===== CRITICAL: SaveValue uses async handler pattern =====
// BC sends PropertyChanges via async Message events OR in synchronous response
// We must wait for async LogicalClientChangeHandler but abort if found in sync response
// Create AbortController to cancel async wait if PropertyChanges found in sync response
const abortController = new AbortController();
// Set up async handler listener BEFORE sending interaction
const asyncHandlerPromise = connection.waitForHandlers((handlers) => {
// Look for LogicalClientChangeHandler with PropertyChanges for our field
const logicalHandler = handlers.find((h) => isLogicalClientChangeHandler(h));
if (logicalHandler) {
moduleLogger.info(`[PropertyChanges] Found async LogicalClientChangeHandler after SaveValue`);
return { matched: true, data: handlers };
}
return { matched: false };
}, { timeoutMs: 1000, signal: abortController.signal } // Pass abort signal
);
// Send interaction
const result = await connection.invoke(interaction);
if (!result.ok) {
abortController.abort(); // Cancel async wait on error
// Suppress the AbortedError from the promise since we're returning early
asyncHandlerPromise.catch(() => { });
return err(new ProtocolError(`Failed to set field "${fieldName}": ${result.error.message}`, { fieldName, value, formId, controlPath, originalError: result.error }));
}
// Check if PropertyChanges are in synchronous response
let foundPropertyChangesInSync = false;
moduleLogger.info(`[PropertyChanges] Checking ${result.value.length} synchronous handlers for PropertyChanges`);
for (const handler of result.value) {
moduleLogger.info(`[PropertyChanges] Sync handler type: ${handler.handlerType}`);
if (isLogicalClientChangeHandler(handler)) {
const params = handler.parameters;
moduleLogger.info(`[PropertyChanges] Found LogicalClientChangeHandler in sync response, params.length=${Array.isArray(params) ? params.length : 'not array'}`);
if (Array.isArray(params) && params.length >= 2) {
const changes = params[1];
moduleLogger.info(`[PropertyChanges] Changes is array: ${Array.isArray(changes)}, length: ${changes.length}`);
for (const change of changes) {
moduleLogger.info(`[PropertyChanges] Change type: ${change?.t}`);
// BC uses both "prc" (PropertyChanges) and "prch" (PropertyChange) type ids
if (isPropertyChanges(change) || isPropertyChange(change)) {
foundPropertyChangesInSync = true;
moduleLogger.info(`[PropertyChanges] Found PropertyChange(s) in sync response!`);
break;
}
}
}
}
if (foundPropertyChangesInSync)
break;
}
// If PropertyChanges found in sync response, cancel async wait to avoid timeout delay
if (foundPropertyChangesInSync) {
abortController.abort();
moduleLogger.info(`[PropertyChanges] Found in synchronous response, cancelled async wait`);
}
else {
moduleLogger.info(`[PropertyChanges] PropertyChanges NOT in sync response, waiting for async...`);
}
// Wait for async PropertyChanges handlers (will be aborted if already found in sync)
// Use Promise.race with setTimeout as a defensive fallback in case AbortSignal.timeout fails
const FALLBACK_TIMEOUT_MS = 2000; // Slightly longer than the 1000ms primary timeout
let asyncHandlers = [];
try {
const fallbackTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Fallback timeout')), FALLBACK_TIMEOUT_MS);
});
asyncHandlers = await Promise.race([asyncHandlerPromise, fallbackTimeoutPromise]);
moduleLogger.info(`[PropertyChanges] Received ${asyncHandlers.length} async handlers after SaveValue`);
}
catch (error) {
// AbortError means we found PropertyChanges in sync response - this is good!
// Timeout (either AbortSignal or fallback) means no async handlers - this is also OK for some fields
const errorObj = error;
if (errorObj?.name === 'AbortError') {
moduleLogger.info(`[PropertyChanges] Async wait aborted (PropertyChanges already in sync response)`);
}
else if (errorObj?.message === 'Fallback timeout') {
moduleLogger.info(`[PropertyChanges] Fallback timeout - no async handlers received`);
}
else {
moduleLogger.info(`[PropertyChanges] No async handlers received (timeout) - this is OK for some fields`);
}
}
// If immediateValidation is enabled, inspect handlers for errors and other messages
if (immediateValidation) {
// Cast to GenericBCHandler for checking special handler types not in standard union
const handlers = result.value;
// Check for BC error messages (blocking errors)
const errorHandler = handlers.find((h) => h.handlerType === 'DN.ErrorMessageProperties' || h.handlerType === 'DN.ErrorDialogProperties');
if (errorHandler) {
const errorParams = errorHandler.parameters?.[0];
const errorMessage = String(errorParams?.Message || errorParams?.ErrorMessage || 'Unknown error');
return err(new ProtocolError(`BC error: ${errorMessage}`, { fieldName, value, formId, controlPath, errorHandler, validationMessage: errorMessage, handlerType: 'error' }));
}
// Check for validation errors (blocking validation)
const validationHandler = handlers.find((h) => h.handlerType === 'DN.ValidationMessageProperties');
if (validationHandler) {
const validationParams = validationHandler.parameters?.[0];
const validationMessage = String(validationParams?.Message || 'Validation failed');
return err(new ProtocolError(`BC validation error: ${validationMessage}`, { fieldName, value, formId, controlPath, validationHandler, validationMessage, handlerType: 'validation' }));
}
// Check for confirmation dialogs (require user interaction)
const confirmHandler = handlers.find((h) => h.handlerType === 'DN.ConfirmDialogProperties' || h.handlerType === 'DN.YesNoDialogProperties');
if (confirmHandler) {
const confirmParams = confirmHandler.parameters?.[0];
const confirmMessage = String(confirmParams?.Message || confirmParams?.ConfirmText || 'Confirmation required');
return err(new ProtocolError(`BC confirmation required: ${confirmMessage}`, { fieldName, value, formId, controlPath, confirmHandler, validationMessage: confirmMessage, handlerType: 'confirm' }));
}
// Note: Info messages and busy states are non-blocking, so we don't fail on t