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

355 lines 15.6 kB
/** * Search Pages MCP Tool * * Searches for BC pages using the Tell Me search protocol. * Connects to BC WebSocket, sends search query, and parses results. * * Protocol: * 1. Open Tell Me dialog: InvokeSessionAction(systemAction: 220) * 2. Submit query: SaveValue with search text * 3. Parse results: Extract from LogicalForm repeater control * * See docs/tell-me-search-protocol.md for full protocol documentation. */ import { BaseMCPTool } from './base-tool.js'; import { ok, isOk, err } from '../core/result.js'; import { ConnectionError, ProtocolError } from '../core/errors.js'; import { SearchPagesInputSchema } from '../validation/schemas.js'; import { BCRawWebSocketClient } from '../connection/clients/BCRawWebSocketClient.js'; import { extractTellMeResults, extractTellMeResultsFromChangeHandler, convertToPageSearchResults, } from '../protocol/logical-form-parser.js'; import { bcConfig } from '../core/config.js'; import { createWorkflowIntegration } from '../services/workflow-integration.js'; /** * MCP Tool: search_pages * * Searches for BC pages by name, caption, or type. * * NOTE: Unlike other tools, this creates its own BCRawWebSocketClient per invocation * because it requires low-level Tell Me protocol access not available through BCPageConnection. * Each search creates a new session, performs the search, and closes. */ export class SearchPagesTool extends BaseMCPTool { config; connectionPool; cache; name = 'search_pages'; description = 'Searches for Business Central pages by caption (page title), optionally filtered by page type. ' + 'query (required): Search term matched against page captions (case-insensitive substring match). ' + 'type (optional): Filter results by page type - must be one of: "List", "Card", "Document", "Worksheet", "Report". ' + 'limit (optional): Maximum results to return (default: 10, maximum: 100). ' + 'Returns array of matching pages: {pageId, pageName, type, description}. Returns empty array if no matches. ' + 'Typical workflow: Use returned pageId with get_page_metadata to open and interact with the specific page.'; /** * Constructor. * SearchPagesTool can optionally use a connection pool and cache for improved performance. * If no pool is provided, falls back to creating a new connection per search. * If no cache is provided, skips caching (direct execution). */ constructor(config, connectionPool, cache) { super({ inputZod: SearchPagesInputSchema }); this.config = config; this.connectionPool = connectionPool; this.cache = cache; } inputSchema = { type: 'object', properties: { query: { type: 'string', description: 'Search query (searches page captions)', }, limit: { type: 'number', description: 'Maximum number of results (default: 10)', minimum: 1, maximum: 100, }, type: { type: 'string', description: 'Filter by page type', enum: ['Card', 'List', 'Document', 'Worksheet', 'Report'], }, workflowId: { type: 'string', description: 'Optional workflow ID to track this operation. Records page searches for workflow audit trail.', }, }, required: ['query'], }; // Consent configuration - Read-only operation, no consent needed requiresConsent = false; sensitivityLevel = 'low'; /** * Executes the tool to search for pages using BC Tell Me protocol. * Input is pre-validated by BaseMCPTool using Zod schema. * * Uses the BC27+ Tell Me search via LogicalClientChangeHandler format. * Requires BC credentials from environment or config. */ async executeInternal(input) { // Input is already validated by BaseMCPTool with Zod const { query, limit = 10, type, workflowId } = input; // Create workflow integration if workflowId provided const workflow = createWorkflowIntegration(workflowId); // Build cache key const cacheKey = `search:${query}:${type || 'all'}:${limit}`; // Use cache if available if (this.cache) { try { return await this.cache.getOrCompute(cacheKey, async () => { return await this.performSearch(query, limit, type, workflow); }, 300000 // 5 minute TTL for search results ); } catch (error) { // If cache operation fails, fall back to direct execution return await this.performSearch(query, limit, type, workflow); } } // No cache - execute directly return await this.performSearch(query, limit, type, workflow); } /** * Perform the actual Tell Me search (called by executeInternal) * @private */ async performSearch(query, limit, type, workflow) { let connState = null; try { // Step 1: Initialize connection connState = await this.initializeConnection(); // Step 2: Validate and get role center form ID const ownerFormId = connState.client.getRoleCenterFormId(); if (!ownerFormId) { await this.cleanupConnection(connState); return err(new ProtocolError('No role center form found in session', {})); } // Step 3: Open Tell Me dialog const dialogResult = await this.openTellMeDialog(connState.client, ownerFormId); if (!isOk(dialogResult)) { await this.cleanupConnection(connState); return dialogResult; } const formId = dialogResult.value; // Step 4: Initialize search with empty value await this.initializeSearchField(connState.client, formId, ownerFormId); // Step 5: Submit search query and get results const searchResult = await this.submitSearchQuery(connState.client, query, formId, ownerFormId); if (!isOk(searchResult)) { await this.cleanupConnection(connState); return searchResult; } // Step 6: Filter and limit results const pages = this.filterAndLimitResults(searchResult.value, type, limit); // Step 7: Cleanup connection await this.cleanupConnection(connState); // Step 8: Record workflow operation this.recordWorkflowOperation(workflow, query, limit, type, pages.length); return ok({ pages, totalCount: pages.length }); } catch (error) { if (connState) await this.cleanupConnection(connState); return err(new ConnectionError(`Tell Me search failed: ${error instanceof Error ? error.message : String(error)}`, { query, error })); } } // ============================================================================ // Helper Methods - Extracted from performSearch for reduced complexity // ============================================================================ /** Get BC credentials from config or defaults */ getCredentials() { return { baseUrl: this.config?.baseUrl || bcConfig.baseUrl, username: this.config?.username || bcConfig.username, password: this.config?.password || bcConfig.password, tenantId: this.config?.tenantId || bcConfig.tenantId, }; } /** Initialize connection from pool or create new */ async initializeConnection() { if (this.connectionPool) { const pooledConnection = await this.connectionPool.acquire(); return { client: pooledConnection.client, pooledConnection, shouldDisconnect: false, }; } const { baseUrl, username, password, tenantId } = this.getCredentials(); // BCRawWebSocketClient expects types.ts BCConfig which has different fields // We provide the essential fields and use type assertion for compatibility const config = { baseUrl, tenantId, environment: 'production', azureClientId: '', azureTenantId: '', azureAuthority: '', roleCenterPageId: 0, }; const client = new BCRawWebSocketClient(config, username, password, tenantId); await client.authenticateWeb(); await client.connect(); await client.openSession({ clientType: 'WebClient', clientVersion: '27.0.0.0', clientCulture: 'en-US', clientTimeZone: 'UTC', }); return { client, pooledConnection: null, shouldDisconnect: true }; } /** Create predicate for Tell Me dialog detection */ createTellMeDialogPredicate() { return (handlers) => { // Try legacy FormToShow format first const legacy = handlers.find((h) => { const handler = h; if (handler.handlerType !== 'DN.LogicalClientEventRaisingHandler') return false; if (handler.parameters?.[0] !== 'FormToShow') return false; const formData = handler.parameters?.[1]; return !!formData?.ServerId; }); if (legacy) { const formData = legacy.parameters?.[1]; return { matched: true, data: formData.ServerId }; } // Try BC27+ ChangeHandler format const change = handlers.find((h) => { const handler = h; if (handler.handlerType !== 'DN.LogicalClientChangeHandler') return false; const params = handler.parameters?.[0]; return params?.Type === 'DataRefreshChange' || params?.Type === 'InitializeChange'; }); if (change) { const params = change.parameters?.[0]; const updates = params?.Updates; if (Array.isArray(updates)) { for (const update of updates) { if (update.NewValue?.ServerId) { return { matched: true, data: update.NewValue.ServerId }; } } } } return { matched: false }; }; } /** Open Tell Me dialog and return form ID */ async openTellMeDialog(client, ownerFormId) { try { const waitDialogPromise = client.waitForHandlers(this.createTellMeDialogPredicate(), { timeoutMs: Math.max(5000, bcConfig.searchTimingWindowMs), }); void client.invoke({ interactionName: 'InvokeSessionAction', namedParameters: { systemAction: 220, ownerForm: ownerFormId, data: { SearchValue: '' } }, openFormIds: [ownerFormId], }).catch(() => { }); const formId = await waitDialogPromise; return ok(formId); } catch (error) { return err(new ProtocolError(`Tell Me dialog did not open: ${error instanceof Error ? error.message : String(error)}`, { error })); } } /** Initialize search field with empty value */ async initializeSearchField(client, formId, ownerFormId) { await client.invoke({ interactionName: 'SaveValue', namedParameters: { newValue: '', isFilterAsYouType: true, alwaysCommitChange: true, isFilterOptimized: false, isSemanticSearch: false, }, controlPath: 'server:c[0]/c[0]', formId, openFormIds: [ownerFormId, formId], }); } /** Create predicate for search results detection */ createSearchResultsPredicate() { return (handlers) => { // Try BC27+ format first (cast to BcHandler[] for type compatibility) const bc27Results = extractTellMeResultsFromChangeHandler(handlers); if (isOk(bc27Results) && bc27Results.value.length > 0) { return { matched: true, data: bc27Results.value }; } // Try legacy format const searchFormHandler = Array.isArray(handlers) ? handlers.find((h) => { const handler = h; return handler.handlerType === 'DN.LogicalClientEventRaisingHandler' && handler.parameters?.[0] === 'FormToShow'; }) : undefined; const logicalForm = searchFormHandler?.parameters?.[1]; if (logicalForm) { const legacyResults = extractTellMeResults({ LogicalForm: logicalForm }); if (isOk(legacyResults) && legacyResults.value.length > 0) { return { matched: true, data: legacyResults.value }; } } return { matched: false }; }; } /** Submit search query and wait for results */ async submitSearchQuery(client, query, formId, ownerFormId) { try { const waitPromise = client.waitForHandlers(this.createSearchResultsPredicate(), { timeoutMs: Math.max(5000, bcConfig.searchTimingWindowMs), }); void client.invoke({ interactionName: 'SaveValue', namedParameters: { newValue: query, isFilterAsYouType: true, alwaysCommitChange: true, isFilterOptimized: false, isSemanticSearch: false, }, controlPath: 'server:c[0]/c[0]', formId, openFormIds: [ownerFormId, formId], }).catch(() => { }); const searchResults = await waitPromise; return ok(searchResults); } catch (error) { return err(new ProtocolError(`Tell Me search results did not arrive: ${error instanceof Error ? error.message : String(error)}`, { query, error })); } } /** Filter by type and apply limit */ filterAndLimitResults(rawResults, type, limit) { let pages = convertToPageSearchResults(rawResults); if (type) { pages = pages.filter(p => p.type === type); } return pages.slice(0, limit); } /** Cleanup connection (release to pool or disconnect) */ async cleanupConnection(connState) { try { if (connState.pooledConnection && this.connectionPool) { await this.connectionPool.release(connState.pooledConnection); } else if (connState.shouldDisconnect) { await connState.client.disconnect(); } } catch { // Swallow cleanup errors - they're not critical } } /** Record operation in workflow */ recordWorkflowOperation(workflow, query, limit, type, resultCount) { if (!workflow) return; workflow.recordOperation('search_pages', { query, limit, type }, { success: true, data: { resultCount } }); } } //# sourceMappingURL=search-pages-tool.js.map