UNPKG

bc-symbols-mcp

Version:

MCP server for analyzing Business Central .app files and BC object structures

886 lines 33 kB
import { AppExtractor } from '../processors/app-extractor.js'; import { SymbolParser } from '../processors/symbol-parser.js'; import { readFileSync, existsSync } from 'fs'; import { stat, readdir } from 'fs/promises'; import { createHash } from 'crypto'; import { join, extname, resolve } from 'path'; export class BCTools { cache; appExtractor; symbolParser; configuredSources = []; discoveredFiles = []; constructor(cache) { this.cache = cache; this.appExtractor = new AppExtractor(); this.symbolParser = new SymbolParser(); } /** * Get all available tools */ getTools() { return [ { name: 'load_app_file', description: 'Load and parse a Business Central .app file', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the .app file to load' } }, required: ['filePath'] } }, { name: 'query_objects', description: 'Query BC objects by type, name, or ID across loaded apps', inputSchema: { type: 'object', properties: { appIds: { type: 'array', items: { type: 'string' }, description: 'App IDs to search in (optional, searches all if not specified)' }, objectType: { type: 'string', description: 'Type of object to search for (table, page, codeunit, etc.)' }, objectName: { type: 'string', description: 'Name of the object to find (optional)' }, objectId: { type: 'number', description: 'ID of the object to find (optional)' }, includeExtensions: { type: 'boolean', description: 'Include extension objects in results (default: true)' } } } }, { name: 'analyze_dependencies', description: 'Analyze dependencies for a specific app', inputSchema: { type: 'object', properties: { appId: { type: 'string', description: 'ID of the app to analyze' }, includeTransitive: { type: 'boolean', description: 'Include transitive dependencies (default: true)' }, direction: { type: 'string', enum: ['incoming', 'outgoing', 'both'], description: 'Direction of dependencies to analyze (default: both)' } }, required: ['appId'] } }, { name: 'find_references', description: 'Find references to a specific object, field, or method', inputSchema: { type: 'object', properties: { appIds: { type: 'array', items: { type: 'string' }, description: 'App IDs to search in (optional, searches all if not specified)' }, objectType: { type: 'string', description: 'Type of the target object' }, objectName: { type: 'string', description: 'Name of the target object' }, fieldName: { type: 'string', description: 'Name of the field to find references to (optional)' }, methodName: { type: 'string', description: 'Name of the method to find references to (optional)' } }, required: ['objectType', 'objectName'] } }, { name: 'get_object_details', description: 'Get detailed information about a specific BC object', inputSchema: { type: 'object', properties: { appId: { type: 'string', description: 'App ID containing the object' }, objectType: { type: 'string', description: 'Type of the object' }, objectIdentifier: { type: 'string', description: 'Name or ID of the object' } }, required: ['appId', 'objectType', 'objectIdentifier'] } }, { name: 'list_loaded_apps', description: 'List all loaded apps with their basic information', inputSchema: { type: 'object', properties: {} } }, { name: 'get_app_info', description: 'Get detailed information about a specific app', inputSchema: { type: 'object', properties: { appId: { type: 'string', description: 'ID of the app' } }, required: ['appId'] } }, { name: 'clear_cache', description: 'Clear the app cache', inputSchema: { type: 'object', properties: {} } }, { name: 'configure_app_sources', description: 'Configure app file paths and directories to analyze', inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths or directory paths containing .app files' }, autoLoad: { type: 'boolean', description: 'Automatically load discovered app files (default: true)' }, recursive: { type: 'boolean', description: 'Scan subdirectories recursively (default: true)' }, replace: { type: 'boolean', description: 'Replace existing configuration instead of appending (default: false)' } }, required: ['paths'] } }, { name: 'get_app_sources', description: 'Get current app source configuration and discovered files', inputSchema: { type: 'object', properties: { rescan: { type: 'boolean', description: 'Rescan directories for app files (default: false)' } } } } ]; } /** * Execute a tool */ async executeTool(name, arguments_) { switch (name) { case 'load_app_file': return this.loadAppFile(arguments_.filePath); case 'query_objects': return this.queryObjects(arguments_); case 'analyze_dependencies': return this.analyzeDependencies(arguments_); case 'find_references': return this.findReferences(arguments_); case 'get_object_details': return this.getObjectDetails(arguments_.appId, arguments_.objectType, arguments_.objectIdentifier); case 'list_loaded_apps': return this.listLoadedApps(); case 'get_app_info': return this.getAppInfo(arguments_.appId); case 'clear_cache': return this.clearCache(); case 'configure_app_sources': return this.configureAppSources(arguments_); case 'get_app_sources': return this.getAppSources(arguments_); default: throw new Error(`Unknown tool: ${name}`); } } /** * Load an app file */ async loadAppFile(filePath) { try { // Calculate file hash to check cache const fileBuffer = readFileSync(filePath); const fileHash = createHash('sha256').update(fileBuffer).digest('hex'); // Check cache first const cachedApp = this.cache.get(filePath, fileHash); if (cachedApp) { return { success: true, message: 'App loaded from cache', app: { id: cachedApp.id, name: cachedApp.name, publisher: cachedApp.publisher, version: cachedApp.version, description: cachedApp.description, objectCounts: this.getObjectCounts(cachedApp) }, cached: true }; } // Extract and parse the app const app = await this.appExtractor.extractApp(filePath); // Store in cache this.cache.set(filePath, app); return { success: true, message: 'App loaded successfully', app: { id: app.id, name: app.name, publisher: app.publisher, version: app.version, description: app.description, platform: app.platform, application: app.application, runtime: app.runtime, target: app.target, dependencyCount: app.dependencies.length, objectCounts: this.getObjectCounts(app) }, cached: false }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Query objects across apps */ async queryObjects(query) { try { const apps = query.appIds ? query.appIds.map(id => this.cache.findAppById(id)).filter(app => app !== null) : this.cache.getValidCachedApps(); if (apps.length === 0) { return { success: false, error: 'No apps found or loaded' }; } const results = []; for (const app of apps) { let objects = []; if (query.objectType) { objects = this.symbolParser.findObjectsByType(app.symbols, query.objectType); } else { // Get all object types if not specified const objectTypes = this.symbolParser.getAvailableObjectTypes(app.symbols); for (const type of objectTypes) { objects.push(...this.symbolParser.findObjectsByType(app.symbols, type).map(obj => ({ ...obj, objectType: type }))); } } // Filter by name if specified if (query.objectName) { objects = objects.filter(obj => obj.name.toLowerCase().includes(query.objectName.toLowerCase())); } // Filter by ID if specified if (query.objectId !== undefined) { objects = objects.filter(obj => obj.id === query.objectId); } // Add app context to results for (const obj of objects) { results.push({ app: { id: app.id, name: app.name, publisher: app.publisher, version: app.version }, object: { id: obj.id, name: obj.name, type: query.objectType || obj.objectType, referenceSourceFileName: obj.referenceSourceFileName, fieldCount: obj.fields ? obj.fields.length : 0, methodCount: obj.methods ? obj.methods.length : 0, properties: obj.properties || [] } }); } } return { success: true, query, resultCount: results.length, results }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Analyze dependencies */ async analyzeDependencies(query) { try { const app = this.cache.findAppById(query.appId); if (!app) { return { success: false, error: `App not found: ${query.appId}` }; } const result = { app: { id: app.id, name: app.name, publisher: app.publisher, version: app.version } }; const direction = query.direction || 'both'; const includeTransitive = query.includeTransitive !== false; if (direction === 'outgoing' || direction === 'both') { result.outgoingDependencies = { direct: app.dependencies, transitive: includeTransitive ? this.cache.getDependencyTree(app.id) : [] }; } if (direction === 'incoming' || direction === 'both') { const directDependents = this.cache.findAppsDependingOn(app.id); result.incomingDependencies = { direct: directDependents.map(depApp => ({ id: depApp.id, name: depApp.name, publisher: depApp.publisher, version: depApp.version })), transitive: includeTransitive ? this.cache.getReverseDependencyTree(app.id) : [] }; } return { success: true, ...result }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Find references to an object/field/method */ async findReferences(query) { try { const apps = query.appIds ? query.appIds.map(id => this.cache.findAppById(id)).filter(app => app !== null) : this.cache.getValidCachedApps(); if (apps.length === 0) { return { success: false, error: 'No apps found or loaded' }; } const references = []; // This is a simplified reference finder // In a full implementation, you would parse the actual AL source code for (const app of apps) { const allObjects = this.getAllObjects(app); for (const obj of allObjects) { // Check if this object references the target const hasReference = this.checkObjectReferences(obj, query); if (hasReference) { references.push({ app: { id: app.id, name: app.name, publisher: app.publisher, version: app.version }, referencingObject: { id: obj.id, name: obj.name, type: obj.objectType, referenceSourceFileName: obj.referenceSourceFileName }, referenceType: this.determineReferenceType(obj, query) }); } } } return { success: true, query, referenceCount: references.length, references }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Get detailed object information */ async getObjectDetails(appId, objectType, objectIdentifier) { try { const app = this.cache.findAppById(appId); if (!app) { return { success: false, error: `App not found: ${appId}` }; } // Try to find by name first, then by ID let object = this.symbolParser.findObjectByName(app.symbols, objectType, objectIdentifier); if (!object && !isNaN(Number(objectIdentifier))) { object = this.symbolParser.findObjectById(app.symbols, objectType, Number(objectIdentifier)); } if (!object) { return { success: false, error: `Object not found: ${objectType} ${objectIdentifier} in app ${appId}` }; } return { success: true, app: { id: app.id, name: app.name, publisher: app.publisher, version: app.version }, object: { ...object, objectType } }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * List all loaded apps */ async listLoadedApps() { try { const apps = this.cache.getValidCachedApps(); return { success: true, appCount: apps.length, apps: apps.map(app => ({ id: app.id, name: app.name, publisher: app.publisher, version: app.version, description: app.description, platform: app.platform, runtime: app.runtime, target: app.target, filePath: app.filePath, dependencyCount: app.dependencies.length, objectCounts: this.getObjectCounts(app) })) }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Get app information */ async getAppInfo(appId) { try { const app = this.cache.findAppById(appId); if (!app) { return { success: false, error: `App not found: ${appId}` }; } return { success: true, app: { ...app, objectCounts: this.getObjectCounts(app), dependencyTree: this.cache.getDependencyTree(app.id), reverseDependencyTree: this.cache.getReverseDependencyTree(app.id) } }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Clear cache */ async clearCache() { try { const stats = this.cache.getStats(); this.cache.clear(); return { success: true, message: 'Cache cleared successfully', previousStats: stats }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Configure app sources */ async configureAppSources(args) { try { const { paths, autoLoad = true, recursive = true, replace = false } = args; // Validate inputs if (!paths || paths.length === 0) { return { success: false, error: 'No paths provided' }; } // Validate and resolve paths const validatedPaths = []; const invalidPaths = []; for (const path of paths) { const resolvedPath = resolve(path); if (existsSync(resolvedPath)) { validatedPaths.push(resolvedPath); } else { invalidPaths.push(path); } } if (invalidPaths.length > 0) { return { success: false, error: `Invalid paths: ${invalidPaths.join(', ')}` }; } // Update configuration if (replace) { this.configuredSources = validatedPaths; } else { // Append new paths, avoiding duplicates for (const path of validatedPaths) { if (!this.configuredSources.includes(path)) { this.configuredSources.push(path); } } } // Scan for app files this.discoveredFiles = await this.scanForAppFiles(this.configuredSources, recursive); // Auto-load discovered apps if requested const loadResults = []; if (autoLoad) { for (const appFile of this.discoveredFiles) { try { const result = await this.loadAppFile(appFile); loadResults.push({ file: appFile, result }); } catch (error) { loadResults.push({ file: appFile, result: { success: false, error: error instanceof Error ? error.message : 'Unknown error' } }); } } } return { success: true, message: 'App sources configured successfully', configuration: { configuredSources: this.configuredSources, discoveredFiles: this.discoveredFiles, fileCount: this.discoveredFiles.length }, autoLoad: { enabled: autoLoad, results: loadResults, successCount: loadResults.filter(r => r.result.success).length, failureCount: loadResults.filter(r => !r.result.success).length } }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Get app sources */ async getAppSources(args = {}) { try { const { rescan = false } = args; if (this.configuredSources.length > 0) { // Scan for app files if requested or if this is the first call if (rescan || this.discoveredFiles.length === 0) { this.discoveredFiles = await this.scanForAppFiles(this.configuredSources, true); } } // Get loaded apps information const loadedApps = this.cache.getValidCachedApps(); const loadedFilePaths = loadedApps.map(app => app.filePath).filter(Boolean); // Categorize discovered files const categorizedFiles = { loaded: this.discoveredFiles.filter(file => loadedFilePaths.includes(file)), notLoaded: this.discoveredFiles.filter(file => !loadedFilePaths.includes(file)) }; return { success: true, configuration: { configuredSources: this.configuredSources, sourceCount: this.configuredSources.length }, discoveredFiles: { files: this.discoveredFiles, totalCount: this.discoveredFiles.length, loaded: categorizedFiles.loaded, loadedCount: categorizedFiles.loaded.length, notLoaded: categorizedFiles.notLoaded, notLoadedCount: categorizedFiles.notLoaded.length }, loadedApps: loadedApps.map(app => ({ id: app.id, name: app.name, publisher: app.publisher, version: app.version, filePath: app.filePath })) }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Scan for app files in configured sources */ async scanForAppFiles(sources, recursive) { const filePromises = sources.map(async (source) => { try { const stats = await stat(source); if (stats.isFile() && extname(source).toLowerCase() === '.app') { return [source]; } else if (stats.isDirectory()) { return await this.scanDirectory(source, recursive); } return []; } catch (error) { // Silently skip sources that can't be accessed return []; } }); const results = await Promise.all(filePromises); const foundFiles = results.flat(); // Remove duplicates and sort return [...new Set(foundFiles)].sort(); } /** * Scan a directory for app files */ async scanDirectory(directory, recursive) { const foundFiles = []; try { const entries = await readdir(directory); for (const entry of entries) { const fullPath = join(directory, entry); try { const stats = await stat(fullPath); if (stats.isFile() && extname(entry).toLowerCase() === '.app') { foundFiles.push(fullPath); } else if (stats.isDirectory() && recursive) { const subdirectoryFiles = await this.scanDirectory(fullPath, recursive); foundFiles.push(...subdirectoryFiles); } } catch (error) { // Silently skip files/directories that can't be accessed } } } catch (error) { // Silently skip directories that can't be read } return foundFiles; } /** * Get configured app sources */ getConfiguredSources() { return [...this.configuredSources]; } /** * Helper: Get object counts for an app */ getObjectCounts(app) { const counts = {}; const objectTypes = this.symbolParser.getAvailableObjectTypes(app.symbols); for (const objectType of objectTypes) { const objects = this.symbolParser.findObjectsByType(app.symbols, objectType); counts[objectType] = objects.length; } return counts; } /** * Helper: Get all objects from an app */ getAllObjects(app) { const objects = []; const objectTypes = this.symbolParser.getAvailableObjectTypes(app.symbols); for (const objectType of objectTypes) { const typeObjects = this.symbolParser.findObjectsByType(app.symbols, objectType); objects.push(...typeObjects.map(obj => ({ ...obj, objectType }))); } return objects; } /** * Helper: Check if an object references the target */ checkObjectReferences(obj, query) { // Simplified reference checking // In a full implementation, this would analyze the actual object structure // Check type definitions for references if (obj.fields) { for (const field of obj.fields) { if (this.typeDefinitionReferences(field.typeDefinition, query)) { return true; } } } if (obj.variables) { for (const variable of obj.variables) { if (this.typeDefinitionReferences(variable.typeDefinition, query)) { return true; } } } if (obj.methods) { for (const method of obj.methods) { if (method.parameters) { for (const param of method.parameters) { if (this.typeDefinitionReferences(param.typeDefinition, query)) { return true; } } } if (method.returnTypeDefinition && this.typeDefinitionReferences(method.returnTypeDefinition, query)) { return true; } } } return false; } /** * Helper: Check if a type definition references the target */ typeDefinitionReferences(typeDef, query) { if (!typeDef || !typeDef.subtype) { return false; } return typeDef.subtype.name === query.objectName; } /** * Helper: Determine reference type */ determineReferenceType(_obj, query) { // Simplified reference type determination if (query.fieldName) { return 'field_reference'; } else if (query.methodName) { return 'method_reference'; } else { return 'type_reference'; } } } //# sourceMappingURL=bc-tools.js.map