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
288 lines • 12.4 kB
JavaScript
/**
* Handler Parser Implementation
*
* Parses BC JSON-RPC responses and extracts handlers.
* Handles decompression, validation, and LogicalForm extraction.
*/
import { gunzipSync } from 'zlib';
import { logger } from '../core/logger.js';
import { ok, err } from '../core/result.js';
import { DecompressionError, InvalidResponseError, LogicalFormParseError, JsonRpcError, } from '../core/errors.js';
/**
* Implementation of IHandlerParser.
* Parses BC WebSocket responses and extracts handlers.
*/
export class HandlerParser {
/**
* Parses handlers from a JSON-RPC response.
* Handles both compressed and uncompressed responses.
*
* @param response - Raw response from BC WebSocket
* @returns Result containing handlers array or error
*/
parse(response) {
// Validate response is an object
if (!this.isObject(response)) {
return err(new InvalidResponseError('Response must be an object', {
receivedType: typeof response,
}));
}
const jsonRpcResponse = response;
// Check for JSON-RPC error
if (jsonRpcResponse.error) {
return err(new JsonRpcError(jsonRpcResponse.error.message, jsonRpcResponse.error.code, { error: jsonRpcResponse.error }));
}
// Handle compressed result
if ('compressedResult' in jsonRpcResponse && jsonRpcResponse.compressedResult) {
return this.parseCompressedResult(jsonRpcResponse.compressedResult);
}
// Handle uncompressed result
if ('result' in jsonRpcResponse && jsonRpcResponse.result) {
return this.parseHandlers(jsonRpcResponse.result);
}
return err(new InvalidResponseError('Response missing both result and compressedResult'));
}
/**
* Extracts formId from CallbackResponseProperties in handlers.
* The formId is returned by BC after OpenForm and identifies which form was opened.
*
* @param handlers - Array of handlers to search
* @returns FormId string if found, undefined otherwise
*/
extractFormId(handlers) {
// Find CallbackResponseProperties handler
const callbackHandler = handlers.find((h) => h.handlerType === 'DN.CallbackResponseProperties');
if (!callbackHandler) {
logger.debug('[HandlerParser] No CallbackResponseProperties handler found');
return undefined;
}
// Extract formId from CompletedInteractions[0].Result.value
const completedInteractions = callbackHandler.parameters?.[0]?.CompletedInteractions;
if (!completedInteractions || completedInteractions.length === 0) {
logger.debug('[HandlerParser] No CompletedInteractions found');
return undefined;
}
const result = completedInteractions[0]?.Result;
const formId = result?.value;
logger.debug({ formId }, '[HandlerParser] Extracted formId from callback');
return formId;
}
/**
* Extracts LogicalForm from FormToShow event in handlers.
* If formId is provided, filters to the specific form by ServerId.
*
* @param handlers - Array of handlers to search
* @param formId - Optional formId to filter by (from OpenForm callback)
* @returns Result containing LogicalForm or error
*/
extractLogicalForm(handlers, formId) {
// Step 1: Find FormToShow handlers
const formToShowHandlers = this.findFormToShowHandlers(handlers);
// Step 2: If no FormToShow, check for dialog error
if (formToShowHandlers.length === 0) {
return this.handleNoFormToShow(handlers);
}
// Step 3: Select the appropriate handler
const formToShowHandler = this.selectFormHandler(formToShowHandlers, formId);
// Step 4: Extract and validate LogicalForm
return this.extractAndValidateLogicalForm(formToShowHandler, handlers);
}
/** Find all FormToShow handlers */
findFormToShowHandlers(handlers) {
return handlers.filter((h) => h.handlerType === 'DN.LogicalClientEventRaisingHandler' &&
h.parameters?.[0] === 'FormToShow');
}
/** Handle case when no FormToShow found - check for dialog error */
handleNoFormToShow(handlers) {
const dialogHandler = handlers.find((h) => h.handlerType === 'DN.LogicalClientEventRaisingHandler' &&
h.parameters?.[0] === 'DialogToShow');
if (dialogHandler) {
const dialogData = dialogHandler.parameters?.[1];
const message = dialogData?.Message || dialogData?.message || 'Unknown dialog';
const caption = dialogData?.Caption || dialogData?.caption || 'Dialog';
return err(new LogicalFormParseError(`Page cannot be opened: ${caption} - ${message}`, {
handlerCount: handlers.length,
handlerTypes: handlers.map(h => h.handlerType),
dialogCaption: caption,
dialogMessage: message,
}));
}
return err(new LogicalFormParseError('No FormToShow event found in handlers', {
handlerCount: handlers.length,
handlerTypes: handlers.map(h => h.handlerType),
}));
}
/** Select the appropriate FormToShow handler based on formId */
selectFormHandler(formToShowHandlers, formId) {
logger.debug({ count: formToShowHandlers.length }, '[HandlerParser] Found FormToShow handlers');
if (!formId) {
logger.debug('[HandlerParser] No formId provided, using first handler');
return formToShowHandlers[0];
}
logger.debug({ formId }, '[HandlerParser] FormId for filtering');
// Log all available ServerIds for debugging
formToShowHandlers.forEach((h, idx) => {
const logicalForm = h.parameters?.[1];
logger.debug(`[HandlerParser] Handler ${idx}: ServerId="${logicalForm?.ServerId}", Caption="${logicalForm?.Caption}"`);
});
// Find matching handler
const matchedHandler = formToShowHandlers.find(h => {
const logicalForm = h.parameters?.[1];
return logicalForm?.ServerId === formId;
});
if (matchedHandler) {
const selectedForm = matchedHandler.parameters?.[1];
logger.debug(`[HandlerParser] Matched handler: ServerId="${selectedForm?.ServerId}", Caption="${selectedForm?.Caption}"`);
return matchedHandler;
}
logger.debug('[HandlerParser] No match found, falling back to first handler');
return formToShowHandlers[0];
}
/** Extract and validate LogicalForm from handler */
extractAndValidateLogicalForm(handler, handlers) {
const logicalForm = handler.parameters?.[1];
if (!logicalForm) {
return err(new LogicalFormParseError('FormToShow event missing LogicalForm in parameters[1]', {
handler,
parametersLength: handler.parameters?.length ?? 0,
}));
}
if (!this.isValidLogicalForm(logicalForm)) {
return err(new LogicalFormParseError('Invalid LogicalForm structure', {
missingFields: this.getMissingLogicalFormFields(logicalForm),
}));
}
return ok(logicalForm);
}
/**
* Extracts LogicalForm from DialogToShow event in handlers.
* Dialogs use the same LogicalForm structure as regular forms.
*
* @param handlers - Array of handlers to search
* @returns Result containing LogicalForm or error
*/
extractDialogForm(handlers) {
// Find DialogToShow handler
const dialogHandler = handlers.find((h) => h.handlerType === 'DN.LogicalClientEventRaisingHandler' &&
h.parameters?.[0] === 'DialogToShow');
if (!dialogHandler) {
return err(new LogicalFormParseError('No DialogToShow event found in handlers', {
handlerCount: handlers.length,
handlerTypes: handlers.map(h => h.handlerType),
}));
}
// Extract LogicalForm from parameters[1]
const logicalForm = dialogHandler.parameters?.[1];
if (!logicalForm) {
return err(new LogicalFormParseError('DialogToShow event missing LogicalForm in parameters[1]', {
handler: dialogHandler,
parametersLength: dialogHandler.parameters?.length ?? 0,
}));
}
// Validate LogicalForm structure
if (!this.isValidLogicalForm(logicalForm)) {
return err(new LogicalFormParseError('Invalid LogicalForm structure for dialog', {
missingFields: this.getMissingLogicalFormFields(logicalForm),
}));
}
// Access BC-specific dialog properties via index signature
logger.debug({
dialogId: logicalForm.ServerId,
caption: logicalForm.Caption,
isTaskDialog: logicalForm['IsTaskDialog'],
isModal: logicalForm['IsModal'],
}, '[HandlerParser] Extracted dialog form');
return ok(logicalForm);
}
// ============================================================================
// Private Helper Methods
// ============================================================================
/**
* Parses compressed result from base64 gzipped data.
*/
parseCompressedResult(compressedResult) {
try {
// Decode base64
const buffer = Buffer.from(compressedResult, 'base64');
// Decompress gzip
const decompressed = gunzipSync(buffer);
// Parse JSON
const json = JSON.parse(decompressed.toString('utf8'));
return this.parseHandlers(json);
}
catch (error) {
if (error instanceof Error) {
return err(new DecompressionError(`Failed to decompress response: ${error.message}`, { originalError: error.message }));
}
return err(new DecompressionError('Failed to decompress response (unknown error)'));
}
}
/**
* Parses handlers from decompressed result.
*/
parseHandlers(result) {
// Result should be an array
if (!Array.isArray(result)) {
return err(new InvalidResponseError('Result must be an array', {
receivedType: typeof result,
}));
}
// Validate each handler has a type
const handlers = [];
for (let i = 0; i < result.length; i++) {
const item = result[i];
if (!this.isObject(item)) {
return err(new InvalidResponseError(`Handler at index ${i} is not an object`, {
index: i,
receivedType: typeof item,
}));
}
if (!('handlerType' in item) || typeof item.handlerType !== 'string') {
return err(new InvalidResponseError(`Handler at index ${i} missing 'handlerType' property`, {
index: i,
handler: item,
}));
}
handlers.push(item);
}
return ok(handlers);
}
/**
* Validates that LogicalForm has required properties.
*/
isValidLogicalForm(form) {
if (!this.isObject(form)) {
return false;
}
const requiredFields = ['ServerId', 'Caption', 'CacheKey'];
for (const field of requiredFields) {
if (!(field in form) || typeof form[field] !== 'string') {
return false;
}
}
return true;
}
/**
* Gets list of missing required fields from LogicalForm.
*/
getMissingLogicalFormFields(form) {
if (!this.isObject(form)) {
return ['<not an object>'];
}
const requiredFields = ['ServerId', 'Caption', 'CacheKey'];
const missing = [];
for (const field of requiredFields) {
if (!(field in form) || typeof form[field] !== 'string') {
missing.push(field);
}
}
return missing;
}
/**
* Type guard for checking if value is an object.
*/
isObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
}
//# sourceMappingURL=handler-parser.js.map