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
265 lines • 10.5 kB
JavaScript
/**
* Search Service
*
* Handles Business Central Tell Me search functionality (Alt+Q).
* Uses event-driven architecture to reliably capture asynchronous search results.
*
* This service layer abstracts the business logic from the MCP tool adapters.
*/
import { ok, err, isOk } from '../core/result.js';
import { ProtocolError, TimeoutError } from '../core/errors.js';
import { ConnectionManager } from '../connection/connection-manager.js';
import { createConnectionLogger } from '../core/logger.js';
import { TellMeParser } from '../protocol/tellme-parser.js';
import { retryWithBackoff } from '../core/retry.js';
import { isDataRefreshChangeType } from '../types/bc-type-discriminators.js';
/** Tell Me system action UUID */
const TELL_ME_ACTION_ID = '{00000000-0000-0000-0300-0000836BD2D2}';
/**
* Service for Business Central search operations
*/
export class SearchService {
parser;
constructor() {
this.parser = new TellMeParser();
}
/**
* Search Business Central pages using Tell Me (Alt+Q)
*/
async searchPages(query, bcConfig) {
const logger = createConnectionLogger('SearchService', 'searchPages');
logger.info({ query }, 'Searching pages');
// Step 1: Validate and setup
const setupResult = await this.setupSearchContext(query, bcConfig, logger);
if (!isOk(setupResult))
return setupResult;
const ctx = setupResult.value;
// Step 2: Open Tell Me dialog
const dialogResult = await this.openTellMeDialog(ctx);
if (!isOk(dialogResult))
return dialogResult;
const { dialogHandlers, searchControlId } = dialogResult.value;
// Step 3: Initialize search
await this.initializeSearch(ctx, searchControlId);
// Step 4: Execute search and get results
const searchResult = await this.executeSearchAndGetResults(ctx, searchControlId);
if (!isOk(searchResult))
return searchResult;
const results = searchResult.value;
// Step 5: Close dialog and return
await this.closeDialog(ctx.connection);
return ok({
query,
results,
totalCount: results.length,
sessionId: ctx.sessionId,
});
}
/** Validate query and establish connection */
async setupSearchContext(query, bcConfig, logger) {
if (!query || query.trim().length === 0) {
return err(new ProtocolError('Search query cannot be empty', { query }));
}
if (!bcConfig) {
return err(new ProtocolError('No BC configuration provided', { query }));
}
const manager = ConnectionManager.getInstance();
const sessionResult = await manager.getOrCreateSession(bcConfig);
if (!isOk(sessionResult)) {
return err(sessionResult.error);
}
const connection = sessionResult.value.connection;
const rawClient = connection;
if (!rawClient.onHandlers || !rawClient.waitForHandlers) {
return err(new ProtocolError('Connection does not support event-driven operations', { query }));
}
return ok({
connection,
rawClient,
sessionId: sessionResult.value.sessionId,
query,
logger,
});
}
/** Open Tell Me dialog with retry */
async openTellMeDialog(ctx) {
ctx.logger.debug('Opening Tell Me dialog');
const openDialogResult = await retryWithBackoff(async () => {
const openResult = await ctx.connection.invoke({
interactionName: 'SystemAction',
namedParameters: { Id: TELL_ME_ACTION_ID },
controlPath: 'server:',
callbackId: '0',
});
if (!isOk(openResult)) {
return openResult;
}
const dialogHandlers = await ctx.rawClient.waitForHandlers((handlers) => {
const found = handlers.some((h) => {
const handler = h;
if (handler.handlerType !== 'DN.FormToShow')
return false;
const params = handler.parameters?.[0];
return params?.Caption?.includes('Tell Me') ?? false;
});
return { matched: found, data: found ? handlers : undefined };
}, { timeoutMs: 5000 });
ctx.logger.debug('Tell Me dialog opened successfully');
return ok(dialogHandlers);
}, {
maxAttempts: 1,
initialDelayMs: 500,
onRetry: () => ctx.logger.debug('Dialog open timeout, retrying...'),
});
if (!isOk(openDialogResult)) {
return err(new TimeoutError('Tell Me dialog did not open after retries', {
query: ctx.query,
timeout: 5000,
error: openDialogResult.error.message,
}));
}
const dialogHandlers = openDialogResult.value;
const searchControlId = this.extractSearchControlId(dialogHandlers);
return ok({ dialogHandlers, searchControlId });
}
/** Extract search control ID from dialog handlers */
extractSearchControlId(dialogHandlers) {
const formToShow = dialogHandlers.find((h) => {
const handler = h;
return handler.handlerType === 'DN.FormToShow';
});
if (formToShow) {
const params = formToShow.parameters?.[0];
if (params?.Controls) {
const searchControl = this.findSearchControl(params.Controls);
if (searchControl) {
return searchControl.ControlId || 'Search';
}
}
}
return 'Search';
}
/** Initialize search (required by BC protocol) */
async initializeSearch(ctx, searchControlId) {
ctx.logger.debug('Initializing search');
const initResult = await ctx.connection.invoke({
interactionName: 'SaveValue',
namedParameters: { controlId: searchControlId, newValue: '' },
controlPath: 'dialog:c[0]',
callbackId: '0',
});
if (!isOk(initResult)) {
ctx.logger.warn({ error: initResult.error }, 'Search initialization failed, continuing anyway');
}
}
/** Execute search and wait for results */
async executeSearchAndGetResults(ctx, searchControlId) {
// Set up listener before executing search
const resultsPromise = ctx.rawClient.waitForHandlers((handlers) => {
const found = handlers.some((h) => {
const handler = h;
if (handler.handlerType === 'DN.LogicalClientChangeHandler') {
const changes = handler.parameters?.[1];
return Array.isArray(changes) && changes.some((change) => isDataRefreshChangeType(change.t) && (change.RowChanges?.length ?? 0) > 0);
}
return false;
});
return { matched: found, data: found ? handlers : undefined };
}, { timeoutMs: 10000 });
// Execute search
ctx.logger.debug({ query: ctx.query }, 'Executing search');
const searchResult = await ctx.connection.invoke({
interactionName: 'SaveValue',
namedParameters: { controlId: searchControlId, newValue: ctx.query },
controlPath: 'dialog:c[0]',
callbackId: '0',
});
if (!isOk(searchResult)) {
return err(new ProtocolError('Failed to execute search', {
query: ctx.query,
error: searchResult.error.message,
}));
}
// Wait for results
let resultHandlers;
try {
resultHandlers = await resultsPromise;
ctx.logger.debug('Search results received');
}
catch {
return err(new TimeoutError('Search results did not arrive within timeout', {
query: ctx.query,
timeout: 10000,
}));
}
// Parse results
const parseResult = this.parser.parseTellMeResults(resultHandlers);
if (!isOk(parseResult)) {
return err(parseResult.error);
}
const pages = parseResult.value;
ctx.logger.info({ query: ctx.query, count: pages.length }, 'Search completed');
return ok(pages.map(page => ({
id: page.id,
caption: page.caption,
type: this.determinePageType(page.caption, page.badges),
tooltip: page.tooltip,
badges: page.badges,
navigable: true,
})));
}
/** Close the Tell Me dialog */
async closeDialog(connection) {
await connection.invoke({
interactionName: 'DialogCancel',
namedParameters: {},
controlPath: 'dialog:c[0]',
callbackId: '0',
});
}
/**
* Find the search control in the dialog controls hierarchy
*/
findSearchControl(controls) {
if (!controls)
return null;
for (const control of controls) {
if (control.Type === 'SearchControl' || control.Caption?.includes('Search')) {
return control;
}
if (control.Controls) {
const found = this.findSearchControl(control.Controls);
if (found)
return found;
}
}
return null;
}
/**
* Determine the type of search result based on caption and badges
*/
determinePageType(caption, badges) {
const captionLower = caption.toLowerCase();
// Check badges first
if (badges?.some(b => b.toLowerCase().includes('report'))) {
return 'Report';
}
if (badges?.some(b => b.toLowerCase().includes('action'))) {
return 'Action';
}
// Check caption
if (captionLower.includes('report'))
return 'Report';
if (captionLower.includes('list'))
return 'Page';
if (captionLower.includes('card'))
return 'Page';
if (captionLower.includes('worksheet'))
return 'Page';
if (captionLower.includes('journal'))
return 'Page';
// Default to Page for BC pages
return 'Page';
}
}
//# sourceMappingURL=search-service.js.map