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

367 lines 19.2 kB
/** * Execute Action Tool * * Executes an action (button click) on a Business Central page. * Uses the InvokeAction interaction to trigger actions like Edit, New, Delete, etc. * * Based on BC_INTERACTION_CAPTURE_PLAN.md - InvokeAction protocol. */ import { ok, err, isOk } from '../core/result.js'; import { ProtocolError } from '../core/errors.js'; import { BaseMCPTool } from './base-tool.js'; import { ConnectionManager } from '../connection/connection-manager.js'; import { createToolLogger } from '../core/logger.js'; import { ExecuteActionInputSchema } from '../validation/schemas.js'; import { PageContextCache } from '../services/page-context-cache.js'; import { createWorkflowIntegration } from '../services/workflow-integration.js'; import { ControlParser } from '../parsers/control-parser.js'; /** * Type guard for LogicalClientEventRaisingHandler */ function isLogicalClientEventRaisingHandler(handler) { return handler.handlerType === 'DN.LogicalClientEventRaisingHandler'; } /** * MCP Tool for executing actions on BC pages. * Implements the InvokeAction interaction protocol. */ export class ExecuteActionTool extends BaseMCPTool { connection; bcConfig; name = 'execute_action'; description = 'Executes an action (button click) on a Business Central page using an existing pageContextId from get_page_metadata. ' + 'actionName: Use action identifiers from get_page_metadata.actions array when available. ' + 'Common well-known actions: "Edit", "New", "Delete", "Post", "Save", "Cancel", "OK", "Yes", "No". ' + 'controlPath (optional): Control path for the action button to disambiguate when multiple actions share similar names. ' + 'Returns: {success, actionName, pageId, formId, message}. ' + 'Side effects: May navigate to another page (requiring new get_page_metadata call) or trigger dialog windows (use handle_dialog to respond). ' + 'WARNING: High-risk operation. Actions like "Post", "Delete", or "Approve" may irreversibly commit or delete data. Use only with explicit consent.'; inputSchema = { type: 'object', properties: { pageContextId: { type: 'string', description: 'Required: Page context ID from get_page_metadata. Ensures action targets the correct page instance.', }, actionName: { type: 'string', description: 'The name of the action to execute (e.g., "Edit", "New", "Delete", "Post", "Save")', }, controlPath: { type: 'string', description: 'Optional: The control path for the action button. If not provided, will attempt automatic lookup.', }, systemAction: { type: 'number', description: 'Optional: The BC systemAction code from action metadata. Required for some actions.', }, workflowId: { type: 'string', description: 'Optional workflow ID to track this operation. For Save/Post actions, automatically clears unsaved changes in workflow.', }, }, required: ['pageContextId', 'actionName'], }; // Consent configuration - High risk operation (can trigger Post, Delete, etc.) requiresConsent = true; sensitivityLevel = 'high'; consentPrompt = 'Execute an action in Business Central? WARNING: Some actions like Post or Delete may be irreversible and cannot be undone.'; constructor(connection, bcConfig, auditLogger) { super({ auditLogger, inputZod: ExecuteActionInputSchema }); this.connection = connection; this.bcConfig = bcConfig; } /** * Executes the action on the BC page. * Input is pre-validated by BaseMCPTool using Zod schema. */ async executeInternal(input) { const { pageContextId, actionName, controlPath, systemAction, key, workflowId } = input; const logger = createToolLogger('execute_action', pageContextId); const workflow = createWorkflowIntegration(workflowId); logger.info(`Executing action "${actionName}" using pageContext: ${pageContextId}`); // Step 1: Parse and validate pageContextId const parseResult = this.parsePageContextId(pageContextId, actionName); if (!isOk(parseResult)) return parseResult; const { actualSessionId, actualPageId } = parseResult.value; // Step 2: Validate session and get connection const sessionResult = this.validateSession(actualSessionId, pageContextId, actionName); if (!isOk(sessionResult)) return sessionResult; const { connection, pageContext, formId } = sessionResult.value; logger.info(`Reusing session: ${actualSessionId}, formId: ${formId}`); // Step 2.5: Auto-lookup controlPath from cached metadata if not provided let resolvedControlPath = controlPath; let resolvedSystemAction = systemAction; if (!controlPath) { const lookup = this.lookupActionFromCache(pageContext, actionName, logger); if (lookup) { resolvedControlPath = lookup.controlPath; // Only use cached systemAction if not explicitly provided if (systemAction === undefined && lookup.systemAction !== undefined) { resolvedSystemAction = lookup.systemAction; } logger.info(`Auto-resolved action "${actionName}": controlPath=${resolvedControlPath}, systemAction=${resolvedSystemAction}`); } else { logger.warn(`Could not find action "${actionName}" in cached metadata - proceeding without controlPath`); } } // Step 3: Build InvokeAction interaction const interaction = this.buildInvokeActionInteraction(formId, resolvedControlPath, resolvedSystemAction, key); logger.info(`Building InvokeAction: controlPath=${resolvedControlPath}, formId=${formId}, systemAction=${resolvedSystemAction}, key=${key}`); // Step 4: Execute action and accumulate handlers const handlersResult = await this.accumulateAsyncHandlers(connection, interaction, actualPageId, actionName, formId, logger); if (!isOk(handlersResult)) return handlersResult; const handlers = handlersResult.value; // Step 5: Auto-track dialogs from handlers await this.autoTrackDialogs(handlers, actualSessionId, logger); // Step 6: Check for errors in handlers const errorResult = this.checkForErrors(handlers, actualPageId, actionName, formId, actualSessionId); if (errorResult) return errorResult; // Step 7: Mark pageContext as stale await this.markPageContextStale(pageContext, pageContextId, logger); // Step 8: Record workflow operation this.recordWorkflowOperation(workflow, pageContextId, actionName, resolvedControlPath, resolvedSystemAction, actualPageId, formId, logger); return ok({ success: true, actionName, pageId: actualPageId, formId, message: `Successfully executed action "${actionName}" on page ${actualPageId}`, handlers, }); } // ============================================================================ // Helper Methods - Extracted from executeInternal for reduced complexity // ============================================================================ /** Parse pageContextId and extract session/page info */ parsePageContextId(pageContextId, actionName) { const contextParts = pageContextId.split(':'); if (contextParts.length < 3) { return err(new ProtocolError(`Invalid pageContextId format: ${pageContextId}`, { pageContextId, actionName })); } return ok({ actualSessionId: contextParts[0], actualPageId: contextParts[2], }); } /** Validate session exists and get connection + pageContext */ validateSession(actualSessionId, pageContextId, actionName) { const manager = ConnectionManager.getInstance(); const connection = manager.getSession(actualSessionId); if (!connection) { return err(new ProtocolError(`Session ${actualSessionId} from pageContext not found. Page may have been closed. Call get_page_metadata again.`, { pageContextId, actionName, sessionId: actualSessionId })); } const pageContext = connection.pageContexts?.get(pageContextId); if (!pageContext) { return err(new ProtocolError(`Page context ${pageContextId} not found. Page may have been closed. Call get_page_metadata again.`, { pageContextId, actionName })); } const formIds = pageContext.formIds || []; if (formIds.length === 0) { return err(new ProtocolError(`No formId found in page context. Page may not be properly opened.`, { pageContextId, actionName })); } return ok({ connection, pageContext, formId: formIds[0] }); } /** Build InvokeAction interaction object */ buildInvokeActionInteraction(formId, controlPath, systemAction, key) { const namedParams = { systemAction: systemAction ?? 0, key: key ?? null, data: {}, repeaterControlTarget: null, }; return { interactionName: 'InvokeAction', skipExtendingSessionLifetime: false, namedParameters: JSON.stringify(namedParams), callbackId: '', controlPath: controlPath || undefined, formId, }; } /** Accumulate async handlers from BC response */ async accumulateAsyncHandlers(connection, interaction, actualPageId, actionName, formId, logger) { const rawClient = connection.getRawClient(); if (!rawClient) { return err(new ProtocolError(`Cannot access raw WebSocket client for async handler capture`, { pageId: actualPageId, actionName, formId })); } const accumulatedHandlers = []; let handlerCount = 0; const ACCUMULATION_WINDOW_MS = 1000; const unsubscribe = rawClient.onHandlers((event) => { if (Array.isArray(event)) { accumulatedHandlers.push(...event); handlerCount++; logger.info(`Received async handler batch #${handlerCount}: ${event.length} handlers`); } else if (event.kind === 'RawHandlers') { accumulatedHandlers.push(...event.handlers); handlerCount++; logger.info(`Received async handler batch #${handlerCount}: ${event.handlers.length} handlers`); } }); try { const invokeResult = await connection.invoke(interaction); if (isOk(invokeResult)) { logger.info(`invoke() returned ${invokeResult.value.length} handlers`); accumulatedHandlers.push(...invokeResult.value); } else { logger.info(`Invoke error: ${invokeResult.error.message}`); } await new Promise((resolve) => setTimeout(resolve, ACCUMULATION_WINDOW_MS)); logger.info(`Action executed, accumulated ${accumulatedHandlers.length} total handlers from ${handlerCount} batches`); } finally { unsubscribe(); } return ok(accumulatedHandlers); } /** Auto-track dialogs from handlers */ async autoTrackDialogs(handlers, actualSessionId, logger) { for (const handler of handlers) { if (isLogicalClientEventRaisingHandler(handler) && handler.parameters?.[0] === 'DialogToShow') { try { const HandlerParser = (await import('../parsers/handler-parser.js')).HandlerParser; const SessionStateManager = (await import('../services/session-state-manager.js')).SessionStateManager; const parser = new HandlerParser(); const dialogFormResult = parser.extractDialogForm([handler]); if (isOk(dialogFormResult)) { const dialogForm = dialogFormResult.value; const dialogFormId = dialogForm.ServerId; const sessionStateManager = SessionStateManager.getInstance(); sessionStateManager.addDialog(actualSessionId, { dialogId: dialogFormId, caption: dialogForm.Caption || 'Dialog', isTaskDialog: !!dialogForm.IsTaskDialog, isModal: !!dialogForm.IsModal, }); logger.info(`Auto-tracked dialog: formId=${dialogFormId}, caption="${dialogForm.Caption}"`); } } catch (error) { logger.warn({ error: String(error) }, 'Failed to auto-track dialog (non-fatal)'); } break; // Only process first dialog } } } /** Check for error handlers in response */ checkForErrors(handlers, actualPageId, actionName, formId, actualSessionId) { const errorHandler = handlers.find((h) => h.handlerType === 'DN.ErrorMessageProperties' || h.handlerType === 'DN.ErrorDialogProperties'); if (errorHandler) { const errorParams = errorHandler.parameters?.[0]; const errorMessage = errorParams?.Message || errorParams?.ErrorMessage || 'Unknown error'; return err(new ProtocolError(`BC returned error: ${errorMessage}`, { pageId: actualPageId, actionName, formId, sessionId: actualSessionId, errorHandler })); } return null; } /** Mark pageContext as stale and clear cache */ async markPageContextStale(pageContext, pageContextId, logger) { try { if (pageContext) { pageContext.needsRefresh = true; logger.info(`Marked pageContext as needing refresh`); } const cache = PageContextCache.getInstance(); await cache.delete(pageContextId); logger.info(`Invalidated persistent cache for pageContext: ${pageContextId}`); } catch (cacheError) { logger.info(`Failed to invalidate cache: ${cacheError}`); } } /** Record operation in workflow */ recordWorkflowOperation(workflow, pageContextId, actionName, controlPath, systemAction, actualPageId, formId, logger) { if (!workflow) return; const commitActions = ['save', 'post', 'ok', 'yes']; const isCommitAction = commitActions.includes(actionName.toLowerCase()); if (isCommitAction) { workflow.clearUnsavedChanges(); logger.info(`Cleared unsaved changes for commit action: ${actionName}`); } workflow.recordOperation('execute_action', { pageContextId, actionName, controlPath, systemAction }, { success: true, data: { actionName, pageId: actualPageId, formId } }); } /** * Look up action from cached page metadata. * Searches for action by name (DesignName) or caption. * Returns controlPath and systemAction if found. */ lookupActionFromCache(pageContext, actionName, logger) { if (!pageContext?.logicalForm) { logger.debug(`[Action Lookup] No logicalForm in pageContext - checking handlers`); // Try to find LogicalForm in handlers const handlers = pageContext?.handlers || []; let logicalForm = null; for (const handler of handlers) { // LogicalForm might be embedded in handlers - check for LogicalForm structure const handlerObj = handler; if (handlerObj?.t === 'lf' || handlerObj?.DesignName || handlerObj?.c) { logicalForm = handlerObj; break; } } if (!logicalForm) { logger.debug(`[Action Lookup] No logicalForm found in handlers either`); return null; } // Use the found logicalForm return this.searchActionsInLogicalForm(logicalForm, actionName, logger); } return this.searchActionsInLogicalForm(pageContext.logicalForm, actionName, logger); } searchActionsInLogicalForm(logicalForm, actionName, logger) { const normalizedName = actionName.toLowerCase().replace(/[&_]/g, ''); // Use ControlParser to properly walk controls and assign controlPath const controlParser = new ControlParser(); const actions = controlParser.extractActions(controlParser.walkControls(logicalForm)); logger.debug(`[Action Lookup] Looking up "${actionName}" (normalized: "${normalizedName}") among ${actions.length} actions`); // Find matching action - prefer exact matches over partial matches // Also prefer real actions over _Promoted variants let bestMatch = null; let bestMatchScore = 0; for (const action of actions) { // Caption includes & character (e.g., "Re&lease"), normalize by removing it const rawCaption = action.caption || ''; const captionNormalized = rawCaption.toLowerCase().replace(/[&_]/g, ''); // Check for match let score = 0; if (captionNormalized === normalizedName) { score = 100; // Exact match after normalization } else if (rawCaption.toLowerCase().replace(/&/g, '') === normalizedName) { score = 90; // Exact match preserving underscore } else if (captionNormalized.includes(normalizedName)) { score = 50; // Partial match } if (score > 0) { // Penalize _Promoted variants (they are toolbar shortcuts, not the actual action) if (rawCaption.includes('_Promoted')) { score -= 30; } logger.debug(`[Action Lookup] Candidate: caption="${rawCaption}", score=${score}, controlPath="${action.controlPath}"`); if (score > bestMatchScore && action.controlPath) { bestMatch = { controlPath: action.controlPath, systemAction: action.systemAction }; bestMatchScore = score; logger.debug(`[Action Lookup] Best match: caption="${rawCaption}", score=${score}, controlPath="${action.controlPath}"`); } } } if (bestMatch) { return bestMatch; } // Log available actions if no match if (actions.length > 0) { const preview = actions.slice(0, 15).map(a => a.caption).join(', '); logger.info(`[Action Lookup] Found ${actions.length} actions, but none matched "${actionName}". Sample: ${preview}`); } return null; } } //# sourceMappingURL=execute-action-tool.js.map