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

218 lines 7.59 kB
import WebSocket from 'ws'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../core/logger.js'; export class BCClient { ws = null; pendingRequests = new Map(); messageQueue = []; connected = false; authenticated = false; config; authHeaders = null; constructor(config) { this.config = config; } /** * Set authentication headers (OAuth Bearer token or Basic Auth) */ setAuthHeaders(headers) { this.authHeaders = headers; } /** * Set OAuth Bearer token for authentication */ setAccessToken(token) { this.authHeaders = { Authorization: `Bearer ${token}` }; } /** * Set NavUserPassword Basic Auth credentials */ setBasicAuth(username, password) { const credentials = `${username}:${password}`; const encoded = Buffer.from(credentials, 'utf-8').toString('base64'); this.authHeaders = { Authorization: `Basic ${encoded}` }; } /** * Connect to Business Central WebSocket endpoint */ async connect() { return new Promise((resolve, reject) => { try { // Construct WebSocket URL based on BC deployment const wsUrl = this.buildWebSocketUrl(); logger.info(`Connecting to: ${wsUrl}`); this.ws = new WebSocket(wsUrl, { headers: this.authHeaders || {} }); this.ws.on('open', () => { logger.info('WebSocket connection established'); this.connected = true; // Process queued messages while (this.messageQueue.length > 0) { const msg = this.messageQueue.shift(); if (msg) { this.ws?.send(JSON.stringify(msg)); } } resolve(); }); this.ws.on('message', (data) => { this.handleMessage(data.toString()); }); this.ws.on('error', (error) => { logger.error({ error }, 'WebSocket error'); reject(error); }); this.ws.on('close', () => { logger.info('WebSocket connection closed'); this.connected = false; this.authenticated = false; }); } catch (error) { reject(error); } }); } /** * Build the WebSocket URL for BC */ buildWebSocketUrl() { // For BC Online: wss://businesscentral.dynamics.com/{tenant}/api/bc/v2.0 // For BC On-Prem: ws://server:port/BC/ws/connect // This is a simplified version - actual BC WebSocket endpoint may differ // Based on decompiled code: WebSocketController.cs handles the endpoint at /ws/connect const { baseUrl, tenantId, environment } = this.config; // BC Online format if (baseUrl.includes('dynamics.com')) { return `wss://businesscentral.dynamics.com/${tenantId}/${environment}/ws/connect`; } // BC On-Prem format // Strip http:// or https:// prefix if present let cleanBaseUrl = baseUrl .replace(/^https?:\/\//, '') // Remove http:// or https:// .replace(/\/+$/, ''); // Remove trailing slashes // Determine protocol (use wss for https, ws for http) const protocol = baseUrl.startsWith('https://') ? 'wss' : 'ws'; // From WebSocketController.cs, the route is [Route("ws")] with [Route("connect")] // So the full path is: /ws/connect return `${protocol}://${cleanBaseUrl}/ws/connect`; } /** * Handle incoming WebSocket messages */ handleMessage(data) { try { const response = JSON.parse(data); // Find pending request const pending = this.pendingRequests.get(response.id); if (pending) { this.pendingRequests.delete(response.id); if (response.error) { pending.reject(new Error(`JSON-RPC Error: ${response.error.message} (${response.error.code})`)); } else { pending.resolve(response.result); } } else { logger.warn({ id: response.id }, 'Received response for unknown request ID'); } } catch (error) { logger.error({ error }, 'Error parsing WebSocket message'); } } /** * Send JSON-RPC request */ async sendRequest(method, params) { return new Promise((resolve, reject) => { const id = uuidv4(); const request = { jsonrpc: "2.0", method, params, id }; // Store pending request this.pendingRequests.set(id, { resolve, reject }); // Send or queue message if (this.connected && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(request)); } else { this.messageQueue.push(request); } // Timeout after 30 seconds setTimeout(() => { if (this.pendingRequests.has(id)) { this.pendingRequests.delete(id); reject(new Error('Request timeout')); } }, 30000); }); } /** * Open connection to BC (IClientApi.OpenConnection) */ async openConnection() { const connectionRequest = { clientType: 'WebClient', clientVersion: '27.0.0.0', // Must match BC server version (BC27) clientCulture: 'en-US', clientTimeZone: 'UTC' }; logger.info('Opening connection...'); const result = await this.sendRequest('OpenConnection', connectionRequest); this.authenticated = true; return result; } /** * Open company (IClientApi.OpenCompany) */ async openCompany(companyName) { const company = companyName || this.config.companyName || ''; logger.info(`Opening company: ${company || '(default)'}`); await this.sendRequest('OpenCompany', { companyName: company }); } /** * Get master page metadata (IClientMetadataApi.GetMasterPage) */ async getMasterPage(pageId) { logger.info(`Fetching metadata for page ${pageId}...`); const result = await this.sendRequest('GetMasterPage', { pageId }); return result; } /** * Close connection */ async disconnect() { if (this.ws) { // Try to send CloseConnection if authenticated if (this.authenticated) { try { await this.sendRequest('CloseConnection', {}); } catch (error) { logger.error({ error }, 'Error closing connection gracefully'); } } this.ws.close(); this.ws = null; this.connected = false; this.authenticated = false; } } /** * Check if connected and authenticated */ isReady() { return this.connected && this.authenticated; } } //# sourceMappingURL=BCWebSocketClient.js.map