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