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
272 lines • 11 kB
JavaScript
/**
* BC Protocol Adapter
*
* Parses BC protocol from raw WebSocket messages and emits typed HandlerEvents.
*
* Responsibilities:
* - Listen to raw WebSocket messages (via IBCWebSocketManager)
* - Decompress gzip-compressed handler responses
* - Extract server sequence numbers from Message events
* - Emit typed HandlerEvents (RawHandlers, Message, FormToShow, etc.)
*
* IMPORTANT: This is a STATELESS service (except for lastServerSequence).
* It does NOT own session state - it only parses protocol and emits events.
* BCSessionManager subscribes to these events to update session state.
*
* Usage:
* ```ts
* const adapter = new BCProtocolAdapter(wsManager, eventEmitter);
* adapter.start();
*
* // Events are emitted automatically as messages arrive
* // BCSessionManager and other consumers subscribe via eventEmitter.onHandlers()
* ```
*/
import { logger } from '../../core/logger.js';
import { isDataRefreshChangeType } from '../../types/bc-type-discriminators.js';
import { decompressHandlers, extractCompressedData, extractSessionInfo, } from './handlers.js';
/**
* BC Protocol Adapter implementation.
*
* Minimal version for Week 2:
* - Handles raw message parsing
* - Decompresses gzipped handlers
* - Tracks server sequence numbers
* - Emits RawHandlers events
*
* Week 3 will add specialized event parsing (FormToShow, SessionInfo, etc.)
*/
export class BCProtocolAdapter {
wsManager;
eventEmitter;
lastServerSequence = -1;
unsubscribe = null;
constructor(wsManager, eventEmitter) {
this.wsManager = wsManager;
this.eventEmitter = eventEmitter;
}
/**
* Start listening to WebSocket messages and parsing BC protocol.
*
* Subscribes to IBCWebSocketManager.onRawMessage() and processes
* all incoming messages.
*
* Idempotent - safe to call multiple times (won't subscribe twice).
*/
start() {
// Idempotent - don't subscribe twice
if (this.unsubscribe) {
logger.info('[BCProtocolAdapter] Already started');
return;
}
logger.info('[BCProtocolAdapter] Starting protocol adapter');
// Subscribe to raw WebSocket messages
this.unsubscribe = this.wsManager.onRawMessage((msg) => {
this.handleRawMessage(msg);
});
}
/**
* Stop listening to WebSocket messages.
*
* Unsubscribes from raw message handler.
*
* Idempotent - safe to call multiple times.
*/
stop() {
if (this.unsubscribe) {
logger.info('[BCProtocolAdapter] Stopping protocol adapter');
this.unsubscribe();
this.unsubscribe = null;
}
}
/**
* Get current server sequence number.
*
* Extracted from Message events during protocol parsing.
*
* @returns Last server sequence number received (-1 if none)
*/
getLastServerSequence() {
return this.lastServerSequence;
}
/** Raw message structure for type-safe access */
static asMessageEnvelope(msg) {
return msg;
}
/**
* Handle raw WebSocket message.
*
* Processes incoming message:
* 1. Track server sequence number (from Message events)
* 2. Decompress handlers (if compressed)
* 3. Emit HandlerEvent
*
* @param msg Raw JSON-RPC message from WebSocket
* @internal
*/
handleRawMessage(msg) {
try {
const typedMsg = BCProtocolAdapter.asMessageEnvelope(msg);
// Track server sequence number from Message events
if (typedMsg.method === 'Message' && typedMsg.params?.[0]?.sequenceNumber !== undefined) {
const serverSeq = typedMsg.params[0].sequenceNumber;
if (serverSeq > this.lastServerSequence) {
this.lastServerSequence = serverSeq;
logger.info(`[BCProtocolAdapter] Server sequence: ${serverSeq}`);
}
// Extract openFormIds from Message event (if present)
const openFormIds = typedMsg.params[0].openFormIds;
// Emit Message event BEFORE processing handlers
// This allows SessionManager to track sequence and openFormIds
const messageEvent = {
kind: 'Message',
sequenceNumber: serverSeq,
openFormIds,
raw: msg,
};
this.eventEmitter.emit(messageEvent);
}
// Check for compressed handlers
const compressed = extractCompressedData(msg);
if (compressed) {
// Decompress and emit RawHandlers event
const handlers = decompressHandlers(compressed);
logger.info(`[BCProtocolAdapter] Decompressed ${handlers.length} handlers`);
// Emit RawHandlers event first
const rawEvent = {
kind: 'RawHandlers',
handlers,
};
this.eventEmitter.emit(rawEvent);
// Week 3: Parse and emit typed events
this.emitTypedEvents(handlers);
}
}
catch (error) {
logger.warn({ error }, '[BCProtocolAdapter] Error handling message');
}
}
/** Emit FormToShow event from LogicalClientEventRaisingHandler. */
emitFormToShow(handler) {
const formData = handler.parameters?.[1];
if (handler.parameters?.[0] !== 'FormToShow' || !formData?.ServerId)
return;
this.eventEmitter.emit({
kind: 'FormToShow',
formId: formData.ServerId,
caption: formData.Caption,
raw: handler,
});
logger.info(`[BCProtocolAdapter] Emitted FormToShow: ${formData.ServerId}`);
}
/** Emit DialogToShow event from LogicalClientEventRaisingHandler. */
emitDialogToShow(handler) {
const dialogData = handler.parameters?.[1];
if (handler.parameters?.[0] !== 'DialogToShow' || !dialogData?.ServerId)
return;
const originatingControl = dialogData.OriginatingControl;
this.eventEmitter.emit({
kind: 'DialogToShow',
dialogId: dialogData.ServerId,
caption: dialogData.Caption || '',
designName: dialogData.DesignName,
isTaskDialog: dialogData.IsTaskDialog,
isModal: dialogData.IsModal,
originatingFormId: originatingControl?.formId,
originatingControlPath: originatingControl?.controlPath,
raw: handler,
});
logger.info(`[BCProtocolAdapter] Emitted DialogToShow: ${dialogData.ServerId}`);
}
/** Emit DataRefreshChange event from LogicalClientChangeHandler. */
emitDataRefreshChange(handler) {
const changes = handler.parameters?.[1];
if (!Array.isArray(changes))
return;
const dataRefreshChanges = changes.filter((c) => isDataRefreshChangeType(c.t));
if (dataRefreshChanges.length === 0)
return;
this.eventEmitter.emit({
kind: 'DataRefreshChange',
updates: dataRefreshChanges,
raw: handler,
});
logger.info(`[BCProtocolAdapter] Emitted DataRefreshChange: ${dataRefreshChanges.length} updates`);
}
/** Emit CallbackResponse event. */
emitCallbackResponse(handler) {
this.eventEmitter.emit({ kind: 'CallbackResponse', raw: handler });
logger.info('[BCProtocolAdapter] Emitted CallbackResponse');
}
/** Emit Error event (ErrorMessage or ErrorDialog). */
emitError(handler) {
const errorType = handler.handlerType === 'DN.ErrorMessageProperties' ? 'ErrorMessage' : 'ErrorDialog';
const msgData = handler.parameters?.[0];
this.eventEmitter.emit({ kind: 'Error', errorType, message: msgData?.Message, raw: handler });
logger.info(`[BCProtocolAdapter] Emitted Error (${errorType}): ${msgData?.Message || 'no message'}`);
}
/** Emit ValidationMessage event. */
emitValidationMessage(handler) {
const msgData = handler.parameters?.[0];
this.eventEmitter.emit({ kind: 'ValidationMessage', message: msgData?.Message, raw: handler });
logger.info(`[BCProtocolAdapter] Emitted ValidationMessage: ${msgData?.Message || 'no message'}`);
}
/** Emit Dialog event (Confirm or YesNo). */
emitDialog(handler) {
const dialogType = handler.handlerType === 'DN.ConfirmDialogProperties' ? 'Confirm' : 'YesNo';
const msgData = handler.parameters?.[0];
this.eventEmitter.emit({ kind: 'Dialog', dialogType, message: msgData?.Message, raw: handler });
logger.info(`[BCProtocolAdapter] Emitted Dialog (${dialogType}): ${msgData?.Message || 'no message'}`);
}
/**
* Parse handlers and emit typed events.
*
* Week 3 enhancement: Extract FormToShow, SessionInfo, and DataRefreshChange
* events from handler arrays.
*
* @param handlers Decompressed handler array
* @internal
*/
emitTypedEvents(handlers) {
// SessionInfo is extracted from the full array (special case)
const sessionInfo = extractSessionInfo(handlers);
if (sessionInfo?.serverSessionId && sessionInfo?.sessionKey && sessionInfo?.companyName) {
this.eventEmitter.emit({
kind: 'SessionInfo',
sessionId: sessionInfo.serverSessionId,
sessionKey: sessionInfo.sessionKey,
company: sessionInfo.companyName,
roleCenterFormId: sessionInfo.roleCenterFormId,
raw: handlers[0],
});
logger.info(`[BCProtocolAdapter] Emitted SessionInfo: ${sessionInfo.companyName}`);
}
// Process each handler once, dispatching to appropriate emitter
for (const handler of handlers) {
switch (handler.handlerType) {
case 'DN.LogicalClientEventRaisingHandler':
this.emitFormToShow(handler);
this.emitDialogToShow(handler);
break;
case 'DN.LogicalClientChangeHandler':
this.emitDataRefreshChange(handler);
break;
case 'DN.CallbackResponseProperties':
this.emitCallbackResponse(handler);
break;
case 'DN.ErrorMessageProperties':
case 'DN.ErrorDialogProperties':
this.emitError(handler);
break;
case 'DN.ValidationMessageProperties':
this.emitValidationMessage(handler);
break;
case 'DN.ConfirmDialogProperties':
case 'DN.YesNoDialogProperties':
this.emitDialog(handler);
break;
}
}
}
}
//# sourceMappingURL=BCProtocolAdapter.js.map