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
332 lines • 14.3 kB
JavaScript
/**
* BC Page Connection - Connection Per Page Architecture
*
* Creates a NEW WebSocket connection for each page request, matching the real BC client behavior.
* This prevents BC's connection-level form caching from affecting different pages.
*
* Solution for: BC caches forms at the WebSocket connection level, causing all pages
* to return the same cached form data when using a single connection.
*/
import { ok, err } from '../core/result.js';
import { ConnectionError, ProtocolError } from '../core/errors.js';
import { BCRawWebSocketClient } from './clients/BCRawWebSocketClient.js';
import { logger } from '../core/logger.js';
/**
* BC Connection that creates a NEW connection for each page request.
* This matches the real BC web client behavior to prevent form caching.
*/
export class BCPageConnection {
config;
currentClient = null;
currentSession;
currentPageId = null;
// Track open forms for compatibility (but we'll create new connections anyway)
openForms = new Map();
// Ack sequence tracking and callback ID for correlation
lastAckSequence = -1;
nextCallbackId = 1;
constructor(config) {
this.config = config;
}
/**
* Creates a fresh connection for page requests.
* For non-page requests, reuses existing connection.
*/
async connect() {
try {
// If we already have a session, return it (for initial connection)
if (this.currentSession) {
return ok(this.currentSession);
}
// Create initial connection
const client = await this.createNewConnection();
this.currentClient = client;
// Build session info (using same pattern as BCSessionConnection)
this.currentSession = {
sessionId: 'bc-page-session',
sessionKey: '',
company: '',
};
return ok(this.currentSession);
}
catch (error) {
return err(new ConnectionError(`Failed to connect to BC: ${String(error)}`, { baseUrl: this.config.baseUrl, error: String(error) }));
}
}
/**
* Creates a new WebSocket connection and authenticates.
*/
async createNewConnection() {
logger.info(`[BCPageConnection] Creating NEW WebSocket connection...`);
// BCRawWebSocketClient expects BCConfig but we only need baseUrl for connection
// The Azure auth fields are not used for on-prem NavUserPassword authentication
const partialConfig = {
baseUrl: this.config.baseUrl,
environment: '',
tenantId: this.config.tenantId || 'default',
azureClientId: '',
azureTenantId: '',
azureAuthority: '',
roleCenterPageId: 0,
};
const client = new BCRawWebSocketClient(partialConfig, this.config.username, this.config.password, this.config.tenantId || 'default');
// Authenticate via web login
await client.authenticateWeb();
// Connect to SignalR hub
await client.connect();
// Open BC session
await client.openSession({
clientType: 'WebClient',
clientVersion: '27.0.0.0', // Must match BC server version (BC27 = 27.0.0.0)
clientCulture: 'en-US',
clientTimeZone: 'UTC',
});
logger.info(`[BCPageConnection] New connection established`);
return client;
}
/**
* Sends an interaction and waits for response.
* Creates fresh connection for each main page OpenForm (Connection Per Page architecture).
* Reuses connection for other interactions within the same page context.
*/
async invoke(interaction) {
try {
// Track page ID for OpenForm calls
const isOpenForm = interaction.interactionName === 'OpenForm';
if (isOpenForm) {
const namedParams = typeof interaction.namedParameters === 'object' && interaction.namedParameters !== null
? interaction.namedParameters
: {};
const queryString = String(namedParams.query || '');
const pageMatch = queryString.match(/page=(\d+)/);
if (pageMatch) {
const newPageId = pageMatch[1];
// Force fresh connection for main page OpenForm to clear BC server state
// BC caches page state per connection - reusing connection causes empty FormToShow
if (this.currentClient) {
logger.info(`[BCPageConnection] Closing connection for new page ${newPageId} (was ${this.currentPageId})`);
await this.close();
}
this.currentPageId = newPageId;
logger.info(`[BCPageConnection] Opening Page ${this.currentPageId} with fresh connection`);
}
}
// Ensure we have a connection (create only if needed)
if (!this.currentClient) {
logger.info(`[BCPageConnection] Creating initial connection...`);
this.currentClient = await this.createNewConnection();
}
// Send the interaction
// FIX: Don't override openFormIds - let BCRawWebSocketClient manage session-level form tracking
// BCPageConnection was incorrectly accumulating formIds across unrelated pages (Page 21 → Page 22)
// causing BC to return empty responses due to form state mismatch
// Note: callbackId is generated internally by BCRawWebSocketClient, no need to pass it
const response = await this.currentClient.invoke({
interactionName: interaction.interactionName,
namedParameters: interaction.namedParameters || {},
controlPath: interaction.controlPath,
formId: interaction.formId,
// Copy readonly array to mutable if provided
openFormIds: interaction.openFormIds ? [...interaction.openFormIds] : undefined,
lastClientAckSequenceNumber: this.lastAckSequence,
});
// Validate response
if (!Array.isArray(response)) {
return err(new ProtocolError('Invalid response from BC: expected array of handlers', {
interaction: interaction.interactionName,
receivedType: typeof response,
}));
}
// Cast response for typed handler processing
const handlers = response;
// Update ack sequence from response
this.updateAckSequenceFromHandlers(handlers);
// Track form if this was an OpenForm
if (isOpenForm) {
const formId = this.extractFormId(handlers);
if (formId && this.currentPageId) {
this.openForms.set(this.currentPageId, formId);
logger.debug(`[BCPageConnection] Tracking form: Page ${this.currentPageId} -> formId ${formId}`);
// CRITICAL: Add form to openFormIds so BC actions work on this form
const rawClient = this.getRawClient();
if (rawClient) {
rawClient.addOpenForm(formId);
}
}
}
return ok(handlers);
}
catch (error) {
const errorMessage = String(error);
return err(new ProtocolError(`Interaction failed: ${errorMessage}`, {
interaction: interaction.interactionName,
error: errorMessage,
}));
}
}
/**
* Extracts form ID from OpenForm response.
* BC returns form ID in DN.LogicalClientEventRaisingHandler with FormToShow event.
*/
extractFormId(handlers) {
try {
// Look for FormToShow event with ServerId
const formShowHandler = handlers.find((h) => h.handlerType === 'DN.LogicalClientEventRaisingHandler' &&
h.parameters?.[0] === 'FormToShow');
if (formShowHandler) {
const params = formShowHandler.parameters;
const formData = params?.[1];
if (formData?.ServerId) {
return formData.ServerId;
}
}
// Fallback: try old callback response format (for compatibility)
const callbackHandler = handlers.find((h) => h.handlerType === 'DN.CallbackResponseProperties');
if (callbackHandler) {
const params = callbackHandler.parameters?.[0];
const completedInteractions = params?.CompletedInteractions;
if (Array.isArray(completedInteractions) && completedInteractions.length > 0) {
return completedInteractions[0].Result?.value ?? null;
}
}
return null;
}
catch {
return null;
}
}
/**
* Updates ack sequence number from handler responses.
* Scans handlers recursively for sequence numbers.
*/
updateAckSequenceFromHandlers(handlers) {
let maxSeq = this.lastAckSequence;
const visit = (obj) => {
if (!obj || typeof obj !== 'object')
return;
for (const [k, v] of Object.entries(obj)) {
const key = k.toLowerCase();
if (typeof v === 'number' &&
(key.includes('sequencenumber') || key.includes('ack') || key.includes('serversequence'))) {
if (v > maxSeq)
maxSeq = v;
}
else if (Array.isArray(v)) {
for (const item of v)
visit(item);
}
else if (v && typeof v === 'object') {
visit(v);
}
}
};
for (const h of handlers)
visit(h);
if (maxSeq > this.lastAckSequence) {
this.lastAckSequence = maxSeq;
logger.debug(`[BCPageConnection] Updated lastAckSequence=${this.lastAckSequence}`);
}
}
/**
* Load child forms using the LoadForm interaction.
*/
async loadChildForms(childForms) {
if (!this.currentClient) {
return err(new ConnectionError('No active connection - call connect() first', { state: 'not_connected' }));
}
logger.info(`[BCPageConnection] Loading ${childForms.length} child forms...`);
const allHandlers = [];
for (const child of childForms) {
try {
// ChildFormInfo has serverId but not controlPath, so use serverId for controlPath
// Note: callbackId is generated internally by BCRawWebSocketClient
const response = await this.currentClient.invoke({
interactionName: 'LoadForm',
formId: child.serverId,
controlPath: child.serverId || 'server:',
namedParameters: {},
openFormIds: undefined, // Let BCRawWebSocketClient manage form tracking
lastClientAckSequenceNumber: this.lastAckSequence,
});
if (Array.isArray(response)) {
allHandlers.push(...response);
logger.debug(`[BCPageConnection] Loaded ${child.serverId}: ${response.length} handlers`);
// Track the loaded child form so openFormIds stays in sync
if (this.currentPageId) {
this.openForms.set(`${this.currentPageId}_child_${child.serverId}`, child.serverId);
logger.debug(`[BCPageConnection] Tracked child form: ${child.serverId}`);
}
}
}
catch (error) {
// Child form load failures are non-fatal (FactBoxes/Parts require parent record context)
logger.warn(`[BCPageConnection] Skipped ${child.serverId} (${String(error)}) - continuing without it`);
}
}
return ok(allHandlers);
}
/**
* Waits for handlers that match a predicate.
* Delegates to the underlying WebSocket client.
*/
async waitForHandlers(predicate, options) {
if (!this.currentClient) {
throw new Error('No active connection - call connect() first');
}
// Wrap predicate to handle unknown[] → Handler[] conversion from raw client
const wrappedPredicate = (handlers) => {
return predicate(handlers);
};
return this.currentClient.waitForHandlers(wrappedPredicate, options);
}
/**
* Gets the underlying raw WebSocket client
*/
getRawClient() {
return this.currentClient;
}
// Compatibility methods
isPageOpen(pageId) {
return this.openForms.has(pageId);
}
getOpenFormId(pageId) {
return this.openForms.get(pageId);
}
trackOpenForm(pageId, formId) {
this.openForms.set(pageId, formId);
}
getAllOpenFormIds() {
return Array.from(new Set(this.openForms.values()));
}
getCompanyName() {
return this.currentClient?.getCompanyName() ?? null;
}
getTenantId() {
return this.currentClient?.getTenantId() ?? 'default';
}
isConnected() {
return this.currentClient !== null && this.currentSession !== undefined;
}
getSession() {
return this.currentSession;
}
/**
* Closes the connection gracefully.
*/
async close() {
try {
if (this.currentClient) {
await this.currentClient.disconnect();
this.currentClient = null;
this.currentSession = undefined;
this.currentPageId = null;
this.openForms.clear();
}
return ok(undefined);
}
catch (error) {
return err(new ConnectionError(`Failed to close connection: ${String(error)}`, { error: String(error) }));
}
}
}
//# sourceMappingURL=bc-page-connection.js.map