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

293 lines 13.5 kB
/** * Create Record By Field Name MCP Tool * * NEW implementation using BCCrudService with automatic field name resolution. * This tool demonstrates the complete LoadForm → Field Resolution → SaveValue flow. * * Advantages over create_record: * - Uses field names/captions instead of internal identifiers * - Automatic LoadForm and field metadata parsing * - Proper oldValue handling from FormState * - Single-flight request safety with CompletedInteractions barriers * - Multi-index field resolution (Caption, ScopedCaption, SourceExpr, Name) */ import { BaseMCPTool } from './base-tool.js'; import { ok, err, isOk } from '../core/result.js'; import { ProtocolError, ConnectionError } from '../core/errors.js'; import { BCCrudService } from '../services/bc-crud-service.js'; import { FormStateService } from '../services/form-state-service.js'; import { createToolLogger } from '../core/logger.js'; /** * MCP Tool: create_record_by_field_name * * Creates a new record using field names/captions for addressing. * Demonstrates the full BCCrudService flow with LoadForm and field resolution. */ export class CreateRecordByFieldNameTool extends BaseMCPTool { connection; bcConfig; name = 'create_record_by_field_name'; description = 'Convenience helper that creates a new Business Central record in a single operation using field names/captions. ' + 'Alternative to the stateful workflow: get_page_metadata → execute_action("New") → write_page_data → execute_action("Post/Save"). ' + 'pageId (required): Target page for creation (e.g., 21 for Customer Card, 22 for Customer List). ' + 'fields (required): Object where keys are field names/captions (e.g., "Name", "Email", "Credit Limit (LCY)") and values are strings. ' + 'Supports scoped field identifiers like "General > Name", "Address/City", or "[SourceExpr]" for AL field targeting. ' + 'formId (optional): Reuse an already-open list page form context if available. ' + 'newButtonPath (optional): Control path of specific "New" button; defaults to systemAction 10. ' + 'Returns: {success, formId, setFields, failedFields: [{field, error}]}. ' + 'Behavior: Automatically resolves field identifiers to controls, enters create mode, sets fields with validation, and saves. ' + 'Use this for simple record creation; use the stateful pageContext workflow for complex scenarios requiring additional logic.'; inputSchema = { type: 'object', properties: { pageId: { type: ['string', 'number'], description: 'BC page ID (e.g., 21 for Customer Card, 22 for Customer List)', }, fields: { type: 'object', description: 'Fields to set. Keys are field names/captions (e.g., "Name", "Email"), values are strings. ' + 'Supports scoped names like "General > Address" and SourceExpr override like "[Customer.Name]".', additionalProperties: { type: 'string' }, }, formId: { type: 'string', description: 'Optional: Form ID if list page is already open', }, newButtonPath: { type: 'string', description: 'Optional: Control path of New button (default: uses systemAction 10)', }, }, required: ['pageId', 'fields'], }; // Consent configuration requiresConsent = true; sensitivityLevel = 'medium'; consentPrompt = 'Create a new record in Business Central using field name addressing? ' + 'This will add data to your BC database.'; crudService = null; constructor(connection, bcConfig, auditLogger) { super({ auditLogger }); this.connection = connection; this.bcConfig = bcConfig; } /** * Initialize BCCrudService lazily */ initCrudService() { if (this.crudService) { return ok(this.crudService); } const rawClient = this.connection.getRawClient(); if (!rawClient) { return err(new ConnectionError('Connection not established. Call connect() first.')); } this.crudService = new BCCrudService(rawClient, new FormStateService()); return ok(this.crudService); } /** * Validates input */ validateInput(input) { const baseResult = super.validateInput(input); if (!isOk(baseResult)) { return baseResult; } // Extract pageId if (!this.hasProperty(input, 'pageId')) { return err(new ProtocolError('Missing required field: pageId')); } const pageIdValue = input.pageId; let pageId; if (typeof pageIdValue === 'string') { pageId = pageIdValue; } else if (typeof pageIdValue === 'number') { pageId = String(pageIdValue); } else { return err(new ProtocolError('pageId must be a string or number')); } // Extract fields const fieldsResult = this.getOptionalObject(input, 'fields'); if (!isOk(fieldsResult)) { return fieldsResult; } const fields = fieldsResult.value; if (!fields || Object.keys(fields).length === 0) { return err(new ProtocolError('No fields provided', { pageId })); } // Convert all field values to strings const stringFields = {}; for (const [key, value] of Object.entries(fields)) { stringFields[key] = String(value); } // Extract optional parameters const formId = this.hasProperty(input, 'formId') ? String(input.formId) : undefined; const newButtonPath = this.hasProperty(input, 'newButtonPath') ? String(input.newButtonPath) : undefined; return ok({ pageId, fields: stringFields, formId, newButtonPath, }); } /** * Executes the tool */ async executeInternal(input) { const logger = createToolLogger('create_record_by_field_name'); // Step 1: Validate input const validatedInput = this.validateInput(input); if (!isOk(validatedInput)) { return validatedInput; } const { pageId, fields, formId: listFormId, newButtonPath } = validatedInput.value; logger.info(`Creating record on Page ${pageId} with field names: ${Object.keys(fields).join(', ')}`); // Step 2: Initialize CRUD service const crudServiceResult = this.initCrudService(); if (!isOk(crudServiceResult)) { return err(crudServiceResult.error); } const crudService = crudServiceResult.value; try { // Step 3: Open page and get list form ID const listResult = await this.ensurePageOpen(crudService, pageId, listFormId, logger); if (!isOk(listResult)) return listResult; const actualListFormId = listResult.value.listFormId; // Step 4: Create new record (click "New" and wait for card form) const cardFormId = await this.createNewRecord(crudService, actualListFormId, newButtonPath, logger); // Step 5: Load metadata and set fields logger.info(`Loading form metadata...`); await crudService.loadForm(cardFormId, { timeoutMs: 10000 }); const fieldResults = await this.setFieldValues(crudService, cardFormId, fields, logger); // Step 6: Close form (auto-saves) logger.info(`Closing and saving record...`); await crudService.closeForm(cardFormId, { timeoutMs: 5000 }); // Step 7: Build result return ok(this.buildSuccessResult(pageId, cardFormId, fields, fieldResults)); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.info(`Failed to create record: ${errorMessage}`); return err(new ProtocolError(`Failed to create record: ${errorMessage}`, { pageId, fields, error: errorMessage, })); } } // ============================================================================ // Helper Methods - Extracted from executeInternal for reduced complexity // ============================================================================ /** Ensure page is open and return list form ID */ async ensurePageOpen(crudService, pageId, existingFormId, logger) { if (existingFormId) { return ok({ listFormId: existingFormId }); } logger.info(`Opening page ${pageId}...`); const client = crudService.getClient(); await client.invoke({ interactionName: 'OpenForm', namedParameters: { query: `tenant=default&page=${pageId}` }, timeoutMs: 10000 }); const listFormId = await client.waitForHandlers(this.createFormToShowPredicate(), { timeoutMs: 5000 }); if (!listFormId) { return err(new ProtocolError('Failed to determine list form ID', { pageId })); } logger.info(`List form opened: ${listFormId}`); return ok({ listFormId }); } /** Handler structure for predicate type safety */ static isFormToShowHandler(h) { return h !== null && typeof h === 'object' && 'handlerType' in h; } /** Create predicate for FormToShow detection */ createFormToShowPredicate() { return (handlers) => { const formShowHandler = handlers.find((h) => { if (!CreateRecordByFieldNameTool.isFormToShowHandler(h)) return false; return h.handlerType === 'DN.LogicalClientEventRaisingHandler' && h.parameters?.[0] === 'FormToShow'; }); if (formShowHandler && CreateRecordByFieldNameTool.isFormToShowHandler(formShowHandler)) { const formData = formShowHandler.parameters?.[1]; return { matched: true, data: formData?.ServerId }; } return { matched: false }; }; } /** Click New button and wait for card form */ async createNewRecord(crudService, listFormId, newButtonPath, logger) { logger.info(`Clicking "New" button...`); const client = crudService.getClient(); const controlPath = newButtonPath || 'server:c[1]/c[0]/c[0]/c[0]'; await crudService.invokeSystemAction(listFormId, 10, controlPath, { timeoutMs: 5000 }); const cardFormId = await client.waitForHandlers(this.createCardFormPredicate(listFormId), { timeoutMs: 5000 }); logger.info(`Card form opened: ${cardFormId}`); return cardFormId; } /** Create predicate for new card form detection */ createCardFormPredicate(listFormId) { return (handlers) => { const formShowHandler = handlers.find((h) => { if (!CreateRecordByFieldNameTool.isFormToShowHandler(h)) return false; if (h.handlerType !== 'DN.LogicalClientEventRaisingHandler') return false; if (h.parameters?.[0] !== 'FormToShow') return false; const formData = h.parameters?.[1]; return formData?.ServerId !== listFormId; }); if (formShowHandler && CreateRecordByFieldNameTool.isFormToShowHandler(formShowHandler)) { const formData = formShowHandler.parameters?.[1]; return { matched: true, data: formData?.ServerId }; } return { matched: false }; }; } /** Set field values and collect results */ async setFieldValues(crudService, cardFormId, fields, logger) { const setFields = []; const failedFields = []; for (const [fieldKey, value] of Object.entries(fields)) { try { logger.info(`Setting field "${fieldKey}" = "${value}"...`); await crudService.saveField(cardFormId, fieldKey, value, { timeoutMs: 5000 }); setFields.push(fieldKey); logger.info(`Field "${fieldKey}" set successfully`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.warn(`Failed to set field "${fieldKey}": ${errorMsg}`); failedFields.push({ field: fieldKey, error: errorMsg }); } } return { setFields, failedFields }; } /** Build success result object */ buildSuccessResult(pageId, cardFormId, fields, fieldResults) { const { setFields, failedFields } = fieldResults; const totalFields = Object.keys(fields).length; return { success: failedFields.length === 0, formId: cardFormId, pageId, setFields, failedFields: failedFields.length > 0 ? failedFields : undefined, message: failedFields.length === 0 ? `Successfully created record with ${setFields.length} field(s)` : `Created record with ${setFields.length}/${totalFields} field(s) (${failedFields.length} failed)`, }; } } //# sourceMappingURL=create-record-by-field-name-tool.js.map