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

258 lines 10 kB
/** * Action Service * * Handles Business Central action execution, including standard actions * (New, Delete, Post) and custom page actions. * * This service layer abstracts the business logic from the MCP tool adapters. */ import { ok, err, isOk } from '../core/result.js'; import { ProtocolError } from '../core/errors.js'; import { ConnectionManager } from '../connection/connection-manager.js'; import { createConnectionLogger } from '../core/logger.js'; import { HandlerParser } from '../parsers/handler-parser.js'; /** Action name to BC interaction name mapping */ const ACTION_MAP = { new: 'New_Rec', delete: 'DeleteRecord', post: 'Post', save: 'SaveRecord', refresh: 'RefreshForm', next: 'NextRecord', previous: 'PreviousRecord', first: 'FirstRecord', last: 'LastRecord', }; /** * Service for executing Business Central actions */ export class ActionService { handlerParser; constructor() { this.handlerParser = new HandlerParser(); } /** * Execute an action on a Business Central page */ async executeAction(pageContextId, actionName, parameters) { const logger = createConnectionLogger('ActionService', 'executeAction'); logger.info({ pageContextId, actionName, parameters }, 'Executing action'); // Step 1: Validate context and get connection const connectionResult = this.validateAndGetConnection(pageContextId); if (!isOk(connectionResult)) return connectionResult; const connection = connectionResult.value; // Step 2: Map action and build parameters const interactionName = this.mapActionToInteraction(actionName); const namedParameters = this.buildNamedParameters(parameters); // Step 3: Execute the action const result = await connection.invoke({ interactionName, namedParameters, controlPath: 'server:c[0]', callbackId: '0', }); // Step 4: Handle result if (!isOk(result)) { return this.buildActionErrorResult(result.error, actionName, pageContextId, logger); } return this.buildActionSuccessResult(result.value, actionName, pageContextId, logger); } /** Validate pageContextId and get connection */ validateAndGetConnection(pageContextId) { const contextParts = pageContextId.split(':'); if (contextParts.length < 3) { return err(new ProtocolError(`Invalid pageContextId format: ${pageContextId}`, { pageContextId })); } const [sessionId] = contextParts; const manager = ConnectionManager.getInstance(); const connection = manager.getSession(sessionId); if (!connection) { return err(new ProtocolError(`Session ${sessionId} not found. Please open a page first.`, { pageContextId, sessionId })); } // Check pageContexts on connection (BCPageConnection has this property) const connectionWithContexts = connection; const pageContext = connectionWithContexts.pageContexts?.get(pageContextId); if (!pageContext) { return err(new ProtocolError(`Page context ${pageContextId} not found. Page may have been closed.`, { pageContextId })); } return ok(connection); } /** Map action name to BC interaction name */ mapActionToInteraction(actionName) { return ACTION_MAP[actionName.toLowerCase()] || actionName; } /** Build named parameters from input */ buildNamedParameters(parameters) { if (!parameters) return {}; const result = {}; for (const [key, value] of Object.entries(parameters)) { result[key] = String(value); } return result; } /** Build error result for failed action */ buildActionErrorResult(error, actionName, pageContextId, logger) { logger.warn({ error }, 'Action execution failed'); const validationErrors = this.extractValidationErrors(error); return ok({ success: false, actionName, pageContextId, message: error.message, validationErrors, }); } /** Build success result, checking for triggered dialogs */ buildActionSuccessResult(handlers, actionName, pageContextId, logger) { const hasDialog = this.checkForDialog(handlers); if (hasDialog) { logger.info('Action triggered a dialog'); return ok({ success: true, actionName, pageContextId, message: 'Action triggered a dialog. Use handle_dialog tool to interact with it.', result: { dialogTriggered: true }, }); } return ok({ success: true, actionName, pageContextId, message: `Action '${actionName}' executed successfully`, }); } /** * Handle dialog interactions */ async handleDialog(pageContextId, fields, action) { const logger = createConnectionLogger('ActionService', 'handleDialog'); logger.info({ pageContextId, fields, action }, 'Handling dialog'); // Extract sessionId from pageContextId const contextParts = pageContextId.split(':'); if (contextParts.length < 3) { return err(new ProtocolError(`Invalid pageContextId format: ${pageContextId}`, { pageContextId })); } const [sessionId] = contextParts; const manager = ConnectionManager.getInstance(); const connection = manager.getSession(sessionId); if (!connection) { return err(new ProtocolError(`Session ${sessionId} not found. Please open a page first.`, { pageContextId, sessionId })); } // Set dialog fields if provided if (fields && fields.length > 0) { for (const field of fields) { const saveResult = await connection.invoke({ interactionName: 'SaveValue', namedParameters: { controlId: field.name, newValue: String(field.value), }, controlPath: 'dialog:c[0]', // Note: dialogs use different control path callbackId: '0', }); if (!isOk(saveResult)) { logger.warn({ field: field.name, error: saveResult.error }, 'Failed to set dialog field'); } } } // Execute dialog action if provided if (action) { const dialogActionMap = { ok: 'DialogOK', cancel: 'DialogCancel', yes: 'DialogYes', no: 'DialogNo', }; const interactionName = dialogActionMap[action.toLowerCase()] || action; const result = await connection.invoke({ interactionName, namedParameters: {}, controlPath: 'dialog:c[0]', callbackId: '0', }); if (!isOk(result)) { return ok({ success: false, actionName: 'handle_dialog', pageContextId, message: `Failed to execute dialog action '${action}': ${result.error.message}`, }); } return ok({ success: true, actionName: 'handle_dialog', pageContextId, message: `Dialog action '${action}' executed successfully`, }); } return ok({ success: true, actionName: 'handle_dialog', pageContextId, message: 'Dialog fields updated successfully', }); } /** Handler structure for dialog check */ static isDialogHandler(h) { return h !== null && typeof h === 'object' && 'handlerType' in h; } /** * Check if handlers contain a dialog */ checkForDialog(handlers) { if (!Array.isArray(handlers)) return false; return handlers.some((handler) => { if (!ActionService.isDialogHandler(handler)) return false; if (handler.handlerType === 'DN.FormToShow') { const formParams = handler.parameters?.[0]; const formType = formParams?.FormType; return formType === 'Dialog' || formType === 'ConfirmDialog'; } return false; }); } /** Error context structure */ static hasContext(e) { return e !== null && typeof e === 'object'; } /** * Extract validation errors from error context */ extractValidationErrors(error) { if (!ActionService.hasContext(error)) return undefined; const context = error.context; if (!context) return undefined; const errors = []; // Check for validation messages in context const validationMessages = context.validationMessages; if (validationMessages && Array.isArray(validationMessages)) { for (const msg of validationMessages) { if (typeof msg === 'string') { errors.push({ message: msg }); } else if (msg && typeof msg === 'object') { const typedMsg = msg; errors.push({ field: typedMsg.field, message: typedMsg.message || String(msg), }); } } } // Check for error in message format if (typeof context.errorMessage === 'string') { errors.push({ message: context.errorMessage, }); } return errors.length > 0 ? errors : undefined; } } //# sourceMappingURL=action-service.js.map