Skip to main content
Glama
tool-router.ts125 kB
/** * Tool Router * * Routes MCP tool calls to either local AL LSP or cloud PartnerCore server * based on tool routing configuration. */ import * as fs from 'fs'; import * as path from 'path'; import { DEFAULT_TOOL_ROUTING, ToolRouting } from '../config/types.js'; import { ALLanguageServer } from '../al/language-server.js'; import { CloudRelayClient } from '../cloud/relay-client.js'; import { ProjectMemory } from '../memory/project-memory.js'; import { BCContainerManager } from '../container/bc-container.js'; import { GitOperations } from '../git/git-operations.js'; import { getLogger } from '../utils/logger.js'; import { findALWorkspace } from '../config/loader.js'; import { sanitizePath, validateToolArgs, sanitizeString, SecurityError, ValidationError, } from '../utils/security.js'; /** * Tool call input */ export interface ToolCall { name: string; arguments: Record<string, unknown>; } /** * Tool call result */ export interface ToolResult { success: boolean; content: unknown; isError?: boolean; } /** * Tool definition for MCP */ export interface ToolDefinition { name: string; description: string; inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[]; }; } /** * Tool Router */ export class ToolRouter { private routing: Map<string, 'local' | 'cloud'>; private alServer: ALLanguageServer | null = null; private cloudClient: CloudRelayClient | null = null; private projectMemory: ProjectMemory | null = null; private containerManager: BCContainerManager | null = null; private gitOperations: GitOperations | null = null; private workspaceRoot: string | null; private logger = getLogger(); private localToolDefinitions: ToolDefinition[] = []; constructor(workspaceRoot?: string, customRouting?: ToolRouting[]) { // Use provided workspace or null (will be detected dynamically) this.workspaceRoot = workspaceRoot || null; this.routing = new Map(); // Apply default routing for (const rule of DEFAULT_TOOL_ROUTING) { this.routing.set(rule.tool, rule.route); } // Apply custom routing overrides if (customRouting) { for (const rule of customRouting) { this.routing.set(rule.tool, rule.route); } } this.initLocalToolDefinitions(); } /** * Set the AL Language Server instance */ setALServer(server: ALLanguageServer): void { this.alServer = server; } /** * Set the Cloud Relay Client instance */ setCloudClient(client: CloudRelayClient): void { this.cloudClient = client; } /** * Set the workspace root explicitly * Used when MCP client provides workspace or user sets it manually */ setWorkspace(workspacePath: string): void { const resolved = path.resolve(workspacePath); this.workspaceRoot = resolved; this.logger.info(`Workspace set to: ${resolved}`); // Reinitialize components that depend on workspace if (this.projectMemory) { this.projectMemory = new ProjectMemory(resolved); } if (this.gitOperations) { this.gitOperations = new GitOperations(resolved); } if (this.containerManager) { this.containerManager = new BCContainerManager(resolved); } } /** * Get the current workspace root (for external access) */ getWorkspace(): string | null { return this.workspaceRoot; } /** * Get workspace root, detecting it dynamically if not set * @param filePath Optional file path to detect workspace from */ private getWorkspaceRoot(filePath?: string): string { // If workspace is already set, use it if (this.workspaceRoot) { return this.workspaceRoot; } // Try to detect from file path if provided (using absolute path) if (filePath) { // If it's an absolute path, try to detect workspace from it const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); const detected = findALWorkspace(path.dirname(absolutePath)); if (detected) { this.workspaceRoot = detected; this.logger.info(`Auto-detected workspace: ${detected} from file: ${filePath}`); return detected; } } // Try to detect from current working directory const detected = findALWorkspace(); if (detected) { this.workspaceRoot = detected; this.logger.info(`Auto-detected workspace: ${detected} from current directory`); return detected; } // Fallback to current directory (will fail validation later) const fallback = process.cwd(); this.logger.warn(`Could not detect AL workspace, using fallback: ${fallback}`); return fallback; } /** * Get all available tool definitions */ async getToolDefinitions(): Promise<ToolDefinition[]> { const tools: ToolDefinition[] = [...this.localToolDefinitions]; // Get cloud tools if (this.cloudClient) { const cloudTools = await this.cloudClient.getTools(); for (const ct of cloudTools) { tools.push({ name: ct.name, description: ct.description, inputSchema: ct.inputSchema as ToolDefinition['inputSchema'], }); } } return tools; } /** * Route and execute a tool call */ async callTool(call: ToolCall): Promise<ToolResult> { const route = this.routing.get(call.name) || 'cloud'; this.logger.debug(`Routing tool ${call.name} to ${route}`); if (route === 'local') { return this.handleLocalTool(call); } else { return this.handleCloudTool(call); } } /** * Handle local tool call (AL LSP or file system) */ private async handleLocalTool(call: ToolCall): Promise<ToolResult> { try { switch (call.name) { case 'al_get_symbols': return this.handleGetSymbols(call.arguments); case 'al_find_symbol': return this.handleFindSymbol(call.arguments); case 'al_find_references': return this.handleFindReferences(call.arguments); case 'al_get_diagnostics': return this.handleGetDiagnostics(call.arguments); case 'al_go_to_definition': return this.handleGoToDefinition(call.arguments); case 'al_hover': return this.handleHover(call.arguments); case 'al_completion': return this.handleCompletion(call.arguments); // New LSP tools (Code Actions, Signature Help, Formatting) case 'al_code_actions': return this.handleCodeActions(call.arguments); case 'al_signature_help': return this.handleSignatureHelp(call.arguments); case 'al_format': return this.handleFormat(call.arguments); // Additional LSP tools (complete coverage) case 'al_document_highlight': return this.handleDocumentHighlight(call.arguments); case 'al_folding_ranges': return this.handleFoldingRanges(call.arguments); case 'al_selection_range': return this.handleSelectionRange(call.arguments); case 'al_type_definition': return this.handleTypeDefinition(call.arguments); case 'al_implementation': return this.handleImplementation(call.arguments); case 'al_format_on_type': return this.handleFormatOnType(call.arguments); case 'al_code_lens': return this.handleCodeLens(call.arguments); case 'al_document_links': return this.handleDocumentLinks(call.arguments); case 'al_execute_command': return this.handleExecuteCommand(call.arguments); case 'al_semantic_tokens': return this.handleSemanticTokens(call.arguments); case 'al_close_document': return this.handleCloseDocument(call.arguments); case 'al_save_document': return this.handleSaveDocument(call.arguments); case 'al_restart_server': return this.handleRestartServer(); case 'al_find_referencing_symbols': return this.handleFindReferencingSymbols(call.arguments); case 'al_insert_before_symbol': return this.handleInsertBeforeSymbol(call.arguments); case 'read_file': return this.handleReadFile(call.arguments); case 'write_file': return this.handleWriteFile(call.arguments); case 'list_files': return this.handleListFiles(call.arguments); case 'search_files': return this.handleSearchFiles(call.arguments); case 'find_file': return this.handleFindFile(call.arguments); case 'replace_content': return this.handleReplaceContent(call.arguments); case 'al_get_started': return this.handleGetStarted(); case 'set_workspace': return this.handleSetWorkspace(call.arguments); // Symbol-based editing tools case 'al_rename_symbol': return this.handleRenameSymbol(call.arguments); case 'al_insert_after_symbol': return this.handleInsertAfterSymbol(call.arguments); case 'al_replace_symbol_body': return this.handleReplaceSymbolBody(call.arguments); // Advanced file operations case 'delete_lines': return this.handleDeleteLines(call.arguments); case 'replace_lines': return this.handleReplaceLines(call.arguments); case 'insert_at_line': return this.handleInsertAtLine(call.arguments); // Project memory tools case 'write_memory': return this.handleWriteMemory(call.arguments); case 'read_memory': return this.handleReadMemory(call.arguments); case 'list_memories': return this.handleListMemories(); case 'delete_memory': return this.handleDeleteMemory(call.arguments); case 'edit_memory': return this.handleEditMemory(call.arguments); // BC Container tools case 'bc_list_containers': return this.handleListContainers(); case 'bc_compile': return this.handleCompile(call.arguments); case 'bc_publish': return this.handlePublish(call.arguments); case 'bc_run_tests': return this.handleRunTests(call.arguments); case 'bc_container_logs': return this.handleContainerLogs(call.arguments); case 'bc_start_container': return this.handleStartContainer(call.arguments); case 'bc_stop_container': return this.handleStopContainer(call.arguments); case 'bc_restart_container': return this.handleRestartContainer(call.arguments); case 'bc_download_symbols': return this.handleDownloadSymbols(call.arguments); case 'bc_create_container': return this.handleCreateContainer(call.arguments); case 'bc_remove_container': return this.handleRemoveContainer(call.arguments); case 'bc_get_extensions': return this.handleGetExtensions(call.arguments); case 'bc_uninstall_app': return this.handleUninstallApp(call.arguments); case 'bc_compile_warnings': return this.handleCompileWarnings(call.arguments); // Git tools case 'git_status': return this.handleGitStatus(); case 'git_diff': return this.handleGitDiff(call.arguments); case 'git_stage': return this.handleGitStage(call.arguments); case 'git_commit': return this.handleGitCommit(call.arguments); case 'git_log': return this.handleGitLog(call.arguments); case 'git_branches': return this.handleGitBranches(call.arguments); case 'git_checkout': return this.handleGitCheckout(call.arguments); case 'git_pull': return this.handleGitPull(call.arguments); case 'git_push': return this.handleGitPush(call.arguments); case 'git_stash': return this.handleGitStash(call.arguments); default: return { success: false, content: `Unknown local tool: ${call.name}`, isError: true, }; } } catch (error) { // Don't log full stack traces for expected errors if (error instanceof SecurityError) { this.logger.warn(`Security error in ${call.name}: ${error.code}`); return { success: false, content: `Access denied: ${error.message}`, isError: true, }; } if (error instanceof ValidationError) { this.logger.debug(`Validation error in ${call.name}: ${error.message}`); return { success: false, content: `Invalid input: ${error.message}`, isError: true, }; } // Log unexpected errors (but sanitize the message) this.logger.error(`Local tool error (${call.name}):`, error); return { success: false, content: error instanceof Error ? error.message : 'An unexpected error occurred', isError: true, }; } } /** * Handle cloud tool call */ private async handleCloudTool(call: ToolCall): Promise<ToolResult> { if (!this.cloudClient) { return { success: false, content: 'Cloud client not configured', isError: true, }; } const response = await this.cloudClient.callTool({ name: call.name, arguments: call.arguments, }); return { success: response.success, content: response.success ? response.result : response.error, isError: !response.success, }; } // ==================== AL LSP Tool Handlers ==================== private async handleGetSymbols(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const symbols = await this.alServer.getDocumentSymbols(uri); return { success: true, content: symbols }; } private async handleFindSymbol(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const query = args['query'] as string; const symbols = await this.alServer.getWorkspaceSymbols(query); return { success: true, content: symbols }; } private async handleFindReferences(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const references = await this.alServer.findReferences(uri, line, character); return { success: true, content: references }; } private async handleGetDiagnostics(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const diagnostics = await this.alServer.getDiagnostics(uri); return { success: true, content: diagnostics }; } private async handleGoToDefinition(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const locations = await this.alServer.goToDefinition(uri, line, character); return { success: true, content: locations }; } private async handleHover(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const hover = await this.alServer.hover(uri, line, character); return { success: true, content: hover }; } private async handleCompletion(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const completions = await this.alServer.getCompletions(uri, line, character); return { success: true, content: completions }; } // ==================== New LSP Tool Handlers ==================== private async handleCodeActions(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; // If line/character provided, create a point range; otherwise use full diagnostics let range: { start: { line: number; character: number }; end: { line: number; character: number } }; if (args['line'] !== undefined && args['character'] !== undefined) { const line = args['line'] as number; const character = args['character'] as number; range = { start: { line, character }, end: { line, character } }; } else if (args['startLine'] !== undefined) { // Range provided range = { start: { line: args['startLine'] as number, character: (args['startCharacter'] as number) || 0 }, end: { line: args['endLine'] as number || args['startLine'] as number, character: (args['endCharacter'] as number) || 0 }, }; } else { // Default to start of file range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; } // Get diagnostics if we should include them let diagnostics; if (args['includeDiagnostics'] !== false) { const diags = await this.alServer.getDiagnostics(uri); diagnostics = diags.filter(d => { // Filter to diagnostics that overlap with the range return d.range.start.line <= range.end.line && d.range.end.line >= range.start.line; }); } const only = args['only'] as string[] | undefined; const actions = await this.alServer.getCodeActions(uri, range, { diagnostics, only }); return { success: true, content: { count: actions.length, actions: actions.map(a => ({ title: a.title, kind: a.kind, isPreferred: a.isPreferred, hasEdit: !!a.edit, hasCommand: !!a.command, })), details: actions, } }; } private async handleSignatureHelp(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character'], { uri: 'string', line: 'number', character: 'number' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const help = await this.alServer.getSignatureHelp(uri, line, character); if (!help) { return { success: true, content: { message: 'No signature help available at this position' } }; } return { success: true, content: { activeSignature: help.activeSignature, activeParameter: help.activeParameter, signatures: help.signatures.map(sig => ({ label: sig.label, documentation: sig.documentation, parameters: sig.parameters?.map(p => ({ label: p.label, documentation: p.documentation, })), })), } }; } private async handleFormat(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; const tabSize = args['tabSize'] as number | undefined; const insertSpaces = args['insertSpaces'] as boolean | undefined; let edits; // Check if range formatting is requested if (args['startLine'] !== undefined) { const range = { start: { line: args['startLine'] as number, character: (args['startCharacter'] as number) || 0 }, end: { line: args['endLine'] as number || args['startLine'] as number, character: (args['endCharacter'] as number) || Number.MAX_SAFE_INTEGER }, }; edits = await this.alServer.formatRange(uri, range, { tabSize, insertSpaces }); } else { // Format entire document edits = await this.alServer.formatDocument(uri, { tabSize, insertSpaces }); } if (edits.length === 0) { return { success: true, content: { message: 'Document is already formatted', edits: [] } }; } // If apply is true, apply the edits to the file if (args['apply'] === true) { const filePath = this.uriToPath(uri); const workspaceRoot = this.getWorkspaceRoot(filePath); const safePath = sanitizePath(filePath, workspaceRoot); let content = fs.readFileSync(safePath, 'utf-8'); const lines = content.split('\n'); // Apply edits in reverse order to maintain positions const sortedEdits = [...edits].sort((a, b) => { if (a.range.start.line !== b.range.start.line) { return b.range.start.line - a.range.start.line; } return b.range.start.character - a.range.start.character; }); for (const edit of sortedEdits) { const startLine = edit.range.start.line; const endLine = edit.range.end.line; const startChar = edit.range.start.character; const endChar = edit.range.end.character; // Get the text before and after the edit range const beforeText = lines.slice(0, startLine).join('\n') + (startLine > 0 ? '\n' : '') + lines[startLine].substring(0, startChar); const afterText = lines[endLine].substring(endChar) + (endLine < lines.length - 1 ? '\n' : '') + lines.slice(endLine + 1).join('\n'); content = beforeText + edit.newText + afterText; // Re-split for next iteration const newLines = content.split('\n'); lines.length = 0; lines.push(...newLines); } fs.writeFileSync(safePath, content, 'utf-8'); return { success: true, content: { message: `Applied ${edits.length} formatting edits`, editsApplied: edits.length, } }; } return { success: true, content: { message: `Found ${edits.length} formatting edits (use apply:true to apply)`, edits: edits.map(e => ({ range: e.range, newText: e.newText.length > 100 ? e.newText.substring(0, 100) + '...' : e.newText, })), } }; } // ==================== Additional LSP Tool Handlers ==================== private async handleDocumentHighlight(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character'], { uri: 'string', line: 'number', character: 'number' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const highlights = await this.alServer.getDocumentHighlights(uri, line, character); return { success: true, content: { count: highlights.length, highlights: highlights.map(h => ({ range: h.range, kind: h.kind || 'text', })), } }; } private async handleFoldingRanges(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; const ranges = await this.alServer.getFoldingRanges(uri); return { success: true, content: { count: ranges.length, ranges: ranges.map(r => ({ startLine: r.startLine, endLine: r.endLine, kind: r.kind, })), } }; } private async handleSelectionRange(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'positions'], { uri: 'string' }); const uri = args['uri'] as string; const positions = args['positions'] as Array<{ line: number; character: number }>; const ranges = await this.alServer.getSelectionRanges(uri, positions); // Flatten nested parents for cleaner output const flattenRange = (sr: { range: { start: { line: number; character: number }; end: { line: number; character: number } }; parent?: unknown }, depth = 0): object[] => { const result: object[] = [{ depth, range: sr.range }]; if (sr.parent && depth < 10) { result.push(...flattenRange(sr.parent as typeof sr, depth + 1)); } return result; }; return { success: true, content: { count: ranges.length, ranges: ranges.map((r, i) => ({ position: positions[i], selections: flattenRange(r), })), } }; } private async handleTypeDefinition(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character'], { uri: 'string', line: 'number', character: 'number' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const locations = await this.alServer.getTypeDefinition(uri, line, character); return { success: true, content: { count: locations.length, locations: locations.map(loc => ({ uri: loc.uri, range: loc.range, })), } }; } private async handleImplementation(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character'], { uri: 'string', line: 'number', character: 'number' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const locations = await this.alServer.getImplementation(uri, line, character); return { success: true, content: { count: locations.length, locations: locations.map(loc => ({ uri: loc.uri, range: loc.range, })), } }; } private async handleFormatOnType(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character', 'ch'], { uri: 'string', line: 'number', character: 'number', ch: 'string' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const ch = args['ch'] as string; const tabSize = args['tabSize'] as number | undefined; const insertSpaces = args['insertSpaces'] as boolean | undefined; const edits = await this.alServer.formatOnType(uri, line, character, ch, { tabSize, insertSpaces }); return { success: true, content: { count: edits.length, edits, } }; } private async handleCodeLens(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; const resolve = args['resolve'] as boolean | undefined; let lenses = await this.alServer.getCodeLenses(uri); // Optionally resolve all lenses if (resolve) { lenses = await Promise.all(lenses.map(lens => this.alServer!.resolveCodeLens(lens))); } return { success: true, content: { count: lenses.length, lenses: lenses.map(l => ({ range: l.range, command: l.command?.title, })), } }; } private async handleDocumentLinks(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; const links = await this.alServer.getDocumentLinks(uri); return { success: true, content: { count: links.length, links: links.map(l => ({ range: l.range, target: l.target, tooltip: l.tooltip, })), } }; } private async handleExecuteCommand(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['command'], { command: 'string' }); const command = args['command'] as string; const commandArgs = args['arguments'] as unknown[] | undefined; try { const result = await this.alServer.executeCommand(command, commandArgs); return { success: true, content: { result } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, content: `Command execution failed: ${errorMessage}`, isError: true }; } } private async handleSemanticTokens(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; // Check if range is provided if (args['startLine'] !== undefined) { const range = { start: { line: args['startLine'] as number, character: (args['startCharacter'] as number) || 0 }, end: { line: args['endLine'] as number || args['startLine'] as number, character: (args['endCharacter'] as number) || Number.MAX_SAFE_INTEGER }, }; const tokens = await this.alServer.getSemanticTokensRange(uri, range); if (!tokens) { return { success: true, content: { message: 'No semantic tokens available' } }; } return { success: true, content: { resultId: tokens.resultId, tokenCount: tokens.data.length / 5, // Each token is 5 integers data: tokens.data.slice(0, 100), // Limit output } }; } const tokens = await this.alServer.getSemanticTokens(uri); if (!tokens) { return { success: true, content: { message: 'No semantic tokens available' } }; } return { success: true, content: { resultId: tokens.resultId, tokenCount: tokens.data.length / 5, data: tokens.data.slice(0, 100), // Limit output for readability } }; } private async handleCloseDocument(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; await this.alServer.closeDocument(uri); return { success: true, content: { message: `Document closed: ${uri}` } }; } private async handleSaveDocument(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri'], { uri: 'string' }); const uri = args['uri'] as string; const text = args['text'] as string | undefined; await this.alServer.saveDocument(uri, text); return { success: true, content: { message: `Document save notification sent: ${uri}` } }; } // ==================== Extended Tool Handlers ==================== private async handleRestartServer(): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } try { await this.alServer.restart(); return { success: true, content: { message: 'AL Language Server restarted successfully' } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, content: `Failed to restart AL Language Server: ${errorMessage}`, isError: true }; } } private async handleFindReferencingSymbols(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character'], { uri: 'string', line: 'number', character: 'number' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const includeDeclaration = args['includeDeclaration'] as boolean | undefined; const contextLinesBefore = args['contextLinesBefore'] as number | undefined; const contextLinesAfter = args['contextLinesAfter'] as number | undefined; const results = await this.alServer.findReferencingSymbols(uri, line, character, { includeDeclaration, contextLinesBefore, contextLinesAfter, }); return { success: true, content: { count: results.length, references: results.map(r => ({ uri: r.location.uri, range: r.location.range, containingSymbol: r.containingSymbol ? { name: r.containingSymbol.name, kind: r.containingSymbol.kind, } : undefined, contextSnippet: r.contextSnippet, })), }, }; } private async handleInsertBeforeSymbol(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'symbolName', 'content'], { uri: 'string', symbolName: 'string', content: 'string' }); const uri = args['uri'] as string; const symbolName = args['symbolName'] as string; const content = args['content'] as string; // Find the symbol const symbol = await this.alServer.findSymbolByName(uri, symbolName); if (!symbol) { return { success: false, content: `Symbol '${symbolName}' not found in ${uri}`, isError: true }; } // Read the file and insert before the symbol const filePath = this.uriToPath(uri); const workspaceRoot = this.getWorkspaceRoot(filePath); const safePath = sanitizePath(filePath, workspaceRoot); const fileContent = fs.readFileSync(safePath, 'utf-8'); const lines = fileContent.split('\n'); const insertLine = symbol.range.start.line; const newContent = content.endsWith('\n') ? content : content + '\n'; lines.splice(insertLine, 0, ...newContent.split('\n').slice(0, -1)); fs.writeFileSync(safePath, lines.join('\n'), 'utf-8'); return { success: true, content: { message: `Inserted content before symbol '${symbolName}' at line ${insertLine + 1}`, insertedAt: insertLine, } }; } private handleFindFile(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['pattern'], { pattern: 'string' }); const pattern = args['pattern'] as string; const directory = (args['directory'] as string) || '.'; const workspaceRoot = this.getWorkspaceRoot(); const searchDir = path.resolve(workspaceRoot, directory); if (!searchDir.startsWith(workspaceRoot)) { return { success: false, content: 'Directory is outside workspace', isError: true }; } const matches: string[] = []; const searchRecursive = (dir: string): void => { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip common ignored directories if (!['node_modules', '.git', '.alpackages', '.output', '.partnercore'].includes(entry.name)) { searchRecursive(fullPath); } } else if (entry.isFile()) { // Check if filename matches pattern (supports * and ? wildcards) const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); if (regex.test(entry.name)) { matches.push(path.relative(workspaceRoot, fullPath)); } } } } catch { // Skip inaccessible directories } }; searchRecursive(searchDir); return { success: true, content: { count: matches.length, files: matches, }, }; } private handleReplaceContent(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['path', 'needle', 'replacement'], { path: 'string', needle: 'string', replacement: 'string' }); const filePath = args['path'] as string; const needle = args['needle'] as string; const replacement = args['replacement'] as string; const mode = (args['mode'] as 'literal' | 'regex') || 'literal'; const allowMultiple = args['allowMultiple'] as boolean | undefined; const workspaceRoot = this.getWorkspaceRoot(filePath); const safePath = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(safePath)) { return { success: false, content: `File not found: ${filePath}`, isError: true }; } let content = fs.readFileSync(safePath, 'utf-8'); let replacements = 0; if (mode === 'regex') { try { const regex = new RegExp(needle, 'gm'); const matches = content.match(regex); replacements = matches ? matches.length : 0; if (replacements === 0) { return { success: false, content: `Pattern '${needle}' not found in file`, isError: true }; } if (replacements > 1 && !allowMultiple) { return { success: false, content: `Pattern matches ${replacements} times. Set allowMultiple:true to replace all.`, isError: true }; } content = content.replace(regex, replacement); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, content: `Invalid regex: ${errorMessage}`, isError: true }; } } else { // Literal mode const occurrences = content.split(needle).length - 1; if (occurrences === 0) { return { success: false, content: `Text '${needle}' not found in file`, isError: true }; } if (occurrences > 1 && !allowMultiple) { return { success: false, content: `Text matches ${occurrences} times. Set allowMultiple:true to replace all.`, isError: true }; } if (allowMultiple) { content = content.split(needle).join(replacement); replacements = occurrences; } else { content = content.replace(needle, replacement); replacements = 1; } } fs.writeFileSync(safePath, content, 'utf-8'); return { success: true, content: { message: `Replaced ${replacements} occurrence(s)`, replacements, }, }; } private handleEditMemory(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['name', 'needle', 'replacement'], { name: 'string', needle: 'string', replacement: 'string' }); const name = args['name'] as string; const needle = args['needle'] as string; const replacement = args['replacement'] as string; const mode = (args['mode'] as 'literal' | 'regex') || 'literal'; const allowMultiple = args['allowMultiple'] as boolean | undefined; const memory = this.getProjectMemory(); const result = memory.editMemory(name, needle, replacement, { mode, allowMultiple }); return { success: result.success, content: result, isError: !result.success, }; } private async handleGetExtensions(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.getExtensions(containerName); return { success: result.success, content: result, isError: !result.success, }; } private async handleUninstallApp(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName', 'name'], { containerName: 'string', name: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.uninstallApp(containerName, { name: args['name'] as string, publisher: args['publisher'] as string | undefined, version: args['version'] as string | undefined, force: args['force'] as boolean | undefined, credential: args['username'] && args['password'] ? { username: args['username'] as string, password: args['password'] as string, } : undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleCompileWarnings(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.compileWarningsOnly(containerName, { appProjectFolder: args['appProjectFolder'] as string | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private uriToPath(uri: string): string { if (uri.startsWith('file:///')) { // Windows: file:///C:/path -> C:/path const path = uri.slice(8); // Handle URL encoding return decodeURIComponent(path); } return uri; } // ==================== File System Tool Handlers ==================== // All file operations are sandboxed to the workspace root for security private handleReadFile(args: Record<string, unknown>): ToolResult { // Validate required arguments validateToolArgs(args, ['path'], { path: 'string' }); const filePath = sanitizeString(args['path'] as string); // Detect workspace dynamically if needed const workspaceRoot = this.getWorkspaceRoot(filePath); // Security: Sanitize path to prevent directory traversal const resolved = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(resolved)) { return { success: false, content: `File not found: ${filePath}`, isError: true }; } const stat = fs.statSync(resolved); if (!stat.isFile()) { return { success: false, content: `Not a file: ${filePath}`, isError: true }; } // Security: Limit file size to prevent memory exhaustion const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (stat.size > MAX_FILE_SIZE) { return { success: false, content: `File too large (max ${MAX_FILE_SIZE} bytes)`, isError: true }; } const content = fs.readFileSync(resolved, 'utf-8'); return { success: true, content }; } private handleWriteFile(args: Record<string, unknown>): ToolResult { // Validate required arguments validateToolArgs(args, ['path', 'content'], { path: 'string', content: 'string' }); const filePath = sanitizeString(args['path'] as string); const content = args['content'] as string; // Detect workspace dynamically if needed const workspaceRoot = this.getWorkspaceRoot(filePath); // Security: Sanitize path to prevent directory traversal const resolved = sanitizePath(filePath, workspaceRoot); const dir = path.dirname(resolved); // Security: Limit content size const MAX_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB if (content.length > MAX_CONTENT_SIZE) { return { success: false, content: `Content too large (max ${MAX_CONTENT_SIZE} bytes)`, isError: true }; } fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(resolved, content, 'utf-8'); // Return relative path in response (don't expose full paths) const relativePath = path.relative(workspaceRoot, resolved); return { success: true, content: `File written: ${relativePath}` }; } private handleListFiles(args: Record<string, unknown>): ToolResult { // Validate required arguments validateToolArgs(args, ['path'], { path: 'string' }); const dirPath = sanitizeString(args['path'] as string); const pattern = args['pattern'] ? sanitizeString(args['pattern'] as string) : undefined; // Detect workspace dynamically if needed const workspaceRoot = this.getWorkspaceRoot(dirPath); // Security: Sanitize path to prevent directory traversal const resolved = sanitizePath(dirPath, workspaceRoot); if (!fs.existsSync(resolved)) { return { success: false, content: `Directory not found: ${dirPath}`, isError: true }; } const stat = fs.statSync(resolved); if (!stat.isDirectory()) { return { success: false, content: `Not a directory: ${dirPath}`, isError: true }; } const files = this.listFilesRecursive(resolved, pattern); // Return relative paths for security const relativePaths = files.map(f => path.relative(workspaceRoot, f)); return { success: true, content: relativePaths }; } private listFilesRecursive(dir: string, pattern?: string, depth = 0): string[] { // Security: Limit recursion depth const MAX_DEPTH = 20; if (depth > MAX_DEPTH) return []; // Use imports from top of file (fs and path are already imported) const results: string[] = []; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { // Silently skip directories we can't read return results; } // Security: Limit total files to prevent DoS const MAX_FILES = 10000; for (const entry of entries) { if (results.length >= MAX_FILES) break; const fullPath = path.join(dir, entry.name); // Security: Skip hidden files/directories and common ignored paths if (entry.name.startsWith('.')) continue; if (entry.isDirectory()) { // Skip common ignored directories const ignoredDirs = ['node_modules', '.git', '.svn', 'dist', 'bin', 'obj', '.alpackages', '.snapshots']; if (!ignoredDirs.includes(entry.name)) { results.push(...this.listFilesRecursive(fullPath, pattern, depth + 1)); } } else { if (!pattern || this.matchesPattern(entry.name, pattern)) { results.push(fullPath); } } } return results; } private matchesPattern(filename: string, pattern: string): boolean { // Security: Escape regex special characters except * and ? const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(`^${escaped}$`, 'i'); return regex.test(filename); } private handleSearchFiles(args: Record<string, unknown>): ToolResult { // Validate required arguments validateToolArgs(args, ['path', 'query'], { path: 'string', query: 'string' }); const dirPath = sanitizeString(args['path'] as string); const query = sanitizeString(args['query'] as string, 1000); // Limit query length const filePattern = args['filePattern'] ? sanitizeString(args['filePattern'] as string) : '*.al'; // Detect workspace dynamically if needed const workspaceRoot = this.getWorkspaceRoot(dirPath); // Security: Sanitize path const resolved = sanitizePath(dirPath, workspaceRoot); const files = this.listFilesRecursive(resolved, filePattern); const results: Array<{ file: string; line: number; content: string }> = []; const queryLower = query.toLowerCase(); // Security: Limit results const MAX_RESULTS = 500; for (const file of files) { if (results.length >= MAX_RESULTS) break; try { const content = fs.readFileSync(file, 'utf-8'); const lines = content.split('\n'); lines.forEach((line, index) => { if (results.length >= MAX_RESULTS) return; if (line.toLowerCase().includes(queryLower)) { results.push({ file: path.relative(workspaceRoot, file), // Return relative paths line: index + 1, content: line.trim().slice(0, 500), // Limit line length in results }); } }); } catch { // Skip files we can't read } } return { success: true, content: results }; } // ==================== Symbol-Based Editing Handlers ==================== private async handleRenameSymbol(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'line', 'character', 'newName'], { uri: 'string', line: 'number', character: 'number', newName: 'string' }); const uri = args['uri'] as string; const line = args['line'] as number; const character = args['character'] as number; const newName = sanitizeString(args['newName'] as string); const workspaceEdit = await this.alServer.renameSymbol(uri, line, character, newName); if (!workspaceEdit) { return { success: false, content: 'Rename not available at this position', isError: true }; } // Apply the edits const appliedFiles: string[] = []; if (workspaceEdit.changes) { for (const [fileUri, edits] of Object.entries(workspaceEdit.changes)) { const filePath = this.uriToPath(fileUri); let content = fs.readFileSync(filePath, 'utf-8'); // Apply edits in reverse order to preserve positions const sortedEdits = [...edits].sort((a, b) => { if (a.range.start.line !== b.range.start.line) { return b.range.start.line - a.range.start.line; } return b.range.start.character - a.range.start.character; }); const lines = content.split('\n'); for (const edit of sortedEdits) { const startLine = edit.range.start.line; const endLine = edit.range.end.line; const startChar = edit.range.start.character; const endChar = edit.range.end.character; if (startLine === endLine) { // Single line edit const line = lines[startLine]; lines[startLine] = line.slice(0, startChar) + edit.newText + line.slice(endChar); } else { // Multi-line edit const firstLine = lines[startLine].slice(0, startChar); const lastLine = lines[endLine].slice(endChar); const newLines = edit.newText.split('\n'); newLines[0] = firstLine + newLines[0]; newLines[newLines.length - 1] += lastLine; lines.splice(startLine, endLine - startLine + 1, ...newLines); } } content = lines.join('\n'); fs.writeFileSync(filePath, content, 'utf-8'); appliedFiles.push(filePath); } } return { success: true, content: { message: `Renamed symbol to '${newName}'`, filesModified: appliedFiles.length, files: appliedFiles, } }; } private async handleInsertAfterSymbol(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'symbolName', 'content'], { uri: 'string', symbolName: 'string', content: 'string' }); const uri = args['uri'] as string; const symbolName = sanitizeString(args['symbolName'] as string); const insertContent = args['content'] as string; const filePath = this.uriToPath(uri); // Find the symbol const symbol = await this.alServer.findSymbolByName(uri, symbolName); if (!symbol) { return { success: false, content: `Symbol '${symbolName}' not found`, isError: true }; } // Read the file and insert after the symbol's end let content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); const insertLine = symbol.range.end.line; // Insert the new content after the symbol lines.splice(insertLine + 1, 0, '', insertContent); content = lines.join('\n'); fs.writeFileSync(filePath, content, 'utf-8'); const workspaceRoot = this.getWorkspaceRoot(filePath); const relativePath = path.relative(workspaceRoot, filePath); return { success: true, content: { message: `Inserted content after symbol '${symbolName}'`, file: relativePath, insertedAtLine: insertLine + 2, // 1-based } }; } private async handleReplaceSymbolBody(args: Record<string, unknown>): Promise<ToolResult> { if (!this.alServer) { return { success: false, content: 'AL Language Server not initialized', isError: true }; } validateToolArgs(args, ['uri', 'symbolName', 'newBody'], { uri: 'string', symbolName: 'string', newBody: 'string' }); const uri = args['uri'] as string; const symbolName = sanitizeString(args['symbolName'] as string); const newBody = args['newBody'] as string; const filePath = this.uriToPath(uri); // Find the symbol const symbol = await this.alServer.findSymbolByName(uri, symbolName); if (!symbol) { return { success: false, content: `Symbol '${symbolName}' not found`, isError: true }; } // Read the file and replace the symbol's range let content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); const startLine = symbol.range.start.line; const endLine = symbol.range.end.line; const startChar = symbol.range.start.character; const endChar = symbol.range.end.character; // Replace the symbol body const before = lines.slice(0, startLine).join('\n'); const firstLinePart = lines[startLine].slice(0, startChar); const lastLinePart = lines[endLine].slice(endChar); const after = lines.slice(endLine + 1).join('\n'); content = before + (before ? '\n' : '') + firstLinePart + newBody + lastLinePart + (after ? '\n' + after : ''); fs.writeFileSync(filePath, content, 'utf-8'); const workspaceRoot = this.getWorkspaceRoot(filePath); const relativePath = path.relative(workspaceRoot, filePath); return { success: true, content: { message: `Replaced body of symbol '${symbolName}'`, file: relativePath, linesReplaced: endLine - startLine + 1, } }; } // ==================== Advanced File Operations ==================== private handleDeleteLines(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['path', 'startLine', 'endLine'], { path: 'string', startLine: 'number', endLine: 'number' }); const filePath = sanitizeString(args['path'] as string); const startLine = args['startLine'] as number; // 1-based const endLine = args['endLine'] as number; // 1-based const workspaceRoot = this.getWorkspaceRoot(filePath); const resolved = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(resolved)) { return { success: false, content: `File not found: ${filePath}`, isError: true }; } let content = fs.readFileSync(resolved, 'utf-8'); const lines = content.split('\n'); if (startLine < 1 || endLine > lines.length || startLine > endLine) { return { success: false, content: `Invalid line range: ${startLine}-${endLine}`, isError: true }; } // Delete lines (convert to 0-based) lines.splice(startLine - 1, endLine - startLine + 1); content = lines.join('\n'); fs.writeFileSync(resolved, content, 'utf-8'); const relativePath = path.relative(workspaceRoot, resolved); return { success: true, content: { message: `Deleted lines ${startLine}-${endLine}`, file: relativePath, linesDeleted: endLine - startLine + 1, } }; } private handleReplaceLines(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['path', 'startLine', 'endLine', 'newContent'], { path: 'string', startLine: 'number', endLine: 'number', newContent: 'string' }); const filePath = sanitizeString(args['path'] as string); const startLine = args['startLine'] as number; // 1-based const endLine = args['endLine'] as number; // 1-based const newContent = args['newContent'] as string; const workspaceRoot = this.getWorkspaceRoot(filePath); const resolved = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(resolved)) { return { success: false, content: `File not found: ${filePath}`, isError: true }; } let content = fs.readFileSync(resolved, 'utf-8'); const lines = content.split('\n'); if (startLine < 1 || endLine > lines.length || startLine > endLine) { return { success: false, content: `Invalid line range: ${startLine}-${endLine}`, isError: true }; } // Replace lines (convert to 0-based) const newLines = newContent.split('\n'); lines.splice(startLine - 1, endLine - startLine + 1, ...newLines); content = lines.join('\n'); fs.writeFileSync(resolved, content, 'utf-8'); const relativePath = path.relative(workspaceRoot, resolved); return { success: true, content: { message: `Replaced lines ${startLine}-${endLine}`, file: relativePath, linesReplaced: endLine - startLine + 1, newLinesCount: newLines.length, } }; } private handleInsertAtLine(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['path', 'line', 'content'], { path: 'string', line: 'number', content: 'string' }); const filePath = sanitizeString(args['path'] as string); const lineNumber = args['line'] as number; // 1-based const insertContent = args['content'] as string; const workspaceRoot = this.getWorkspaceRoot(filePath); const resolved = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(resolved)) { return { success: false, content: `File not found: ${filePath}`, isError: true }; } let content = fs.readFileSync(resolved, 'utf-8'); const lines = content.split('\n'); if (lineNumber < 1 || lineNumber > lines.length + 1) { return { success: false, content: `Invalid line number: ${lineNumber}`, isError: true }; } // Insert at line (convert to 0-based) const newLines = insertContent.split('\n'); lines.splice(lineNumber - 1, 0, ...newLines); content = lines.join('\n'); fs.writeFileSync(resolved, content, 'utf-8'); const relativePath = path.relative(workspaceRoot, resolved); return { success: true, content: { message: `Inserted ${newLines.length} line(s) at line ${lineNumber}`, file: relativePath, linesInserted: newLines.length, } }; } // ==================== Project Memory Handlers ==================== private getProjectMemory(): ProjectMemory { if (!this.projectMemory) { const workspaceRoot = this.getWorkspaceRoot(); this.projectMemory = new ProjectMemory(workspaceRoot); } return this.projectMemory; } private handleWriteMemory(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['name', 'content'], { name: 'string', content: 'string' }); const name = sanitizeString(args['name'] as string); const content = args['content'] as string; const tags = args['tags'] as string[] | undefined; const memory = this.getProjectMemory(); const result = memory.writeMemory(name, content, tags); return { success: true, content: { message: `Memory '${name}' saved`, memory: { name: result.name, createdAt: result.createdAt, updatedAt: result.updatedAt, tags: result.tags, } } }; } private handleReadMemory(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['name'], { name: 'string' }); const name = sanitizeString(args['name'] as string); const memory = this.getProjectMemory(); const result = memory.readMemory(name); if (!result) { return { success: false, content: `Memory '${name}' not found`, isError: true }; } return { success: true, content: result }; } private handleListMemories(): ToolResult { const memory = this.getProjectMemory(); const memories = memory.listMemories(); return { success: true, content: { count: memories.length, memories: memories.map(m => ({ name: m.name, updatedAt: m.updatedAt, tags: m.tags, preview: m.content.slice(0, 100) + (m.content.length > 100 ? '...' : ''), })), } }; } private handleDeleteMemory(args: Record<string, unknown>): ToolResult { validateToolArgs(args, ['name'], { name: 'string' }); const name = sanitizeString(args['name'] as string); const memory = this.getProjectMemory(); const deleted = memory.deleteMemory(name); if (!deleted) { return { success: false, content: `Memory '${name}' not found`, isError: true }; } return { success: true, content: { message: `Memory '${name}' deleted` } }; } // ==================== BC Container Handlers ==================== private getContainerManager(): BCContainerManager { if (!this.containerManager) { const workspaceRoot = this.getWorkspaceRoot(); this.containerManager = new BCContainerManager(workspaceRoot); } return this.containerManager; } private async handleListContainers(): Promise<ToolResult> { const manager = this.getContainerManager(); const containers = await manager.listContainers(); return { success: true, content: { count: containers.length, containers: containers.map(c => ({ name: c.name, image: c.image, status: c.status, running: c.running, ports: c.ports, })), } }; } private async handleCompile(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.compile(containerName, { appProjectFolder: args['appProjectFolder'] as string | undefined, outputFolder: args['outputFolder'] as string | undefined, }); return { success: result.success, content: { success: result.success, appFile: result.appFile, errors: result.errors, warnings: result.warnings, duration: `${(result.duration / 1000).toFixed(2)}s`, }, isError: !result.success, }; } private async handlePublish(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.publish(containerName, { appFile: args['appFile'] as string | undefined, syncMode: args['syncMode'] as 'Add' | 'Clean' | 'Development' | 'ForceSync' | undefined, skipVerification: args['skipVerification'] as boolean | undefined, install: args['install'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleRunTests(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.runTests(containerName, { testCodeunit: args['testCodeunit'] as number | undefined, testFunction: args['testFunction'] as string | undefined, extensionId: args['extensionId'] as string | undefined, detailed: args['detailed'] as boolean | undefined, }); return { success: result.success, content: { success: result.success, testsRun: result.testsRun, testsPassed: result.testsPassed, testsFailed: result.testsFailed, testsSkipped: result.testsSkipped, duration: `${(result.duration / 1000).toFixed(2)}s`, results: result.results.slice(0, 50), // Limit results to prevent huge payloads }, isError: !result.success, }; } private async handleContainerLogs(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const logs = await manager.getLogs(containerName, { tail: args['tail'] as number | undefined, since: args['since'] as string | undefined, }); return { success: true, content: logs, }; } private async handleStartContainer(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.startContainer(containerName); return { success: result.success, content: result, isError: !result.success, }; } private async handleStopContainer(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.stopContainer(containerName); return { success: result.success, content: result, isError: !result.success, }; } private async handleRestartContainer(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.restartContainer(containerName); return { success: result.success, content: result, isError: !result.success, }; } private async handleDownloadSymbols(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); const result = await manager.downloadSymbols( containerName, args['targetFolder'] as string | undefined ); return { success: result.success, content: result, isError: !result.success, }; } private async handleCreateContainer(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const manager = this.getContainerManager(); // Build options from arguments const options = { artifactUrl: args['artifactUrl'] as string | undefined, version: args['version'] as string | undefined, country: args['country'] as string | undefined, type: args['type'] as 'OnPrem' | 'Sandbox' | undefined, auth: args['auth'] as 'UserPassword' | 'NavUserPassword' | 'Windows' | 'AAD' | undefined, credential: args['username'] && args['password'] ? { username: args['username'] as string, password: args['password'] as string, } : undefined, licenseFile: args['licenseFile'] as string | undefined, accept_eula: args['accept_eula'] !== false, accept_outdated: args['accept_outdated'] as boolean | undefined, includeTestToolkit: args['includeTestToolkit'] as boolean | undefined, includeTestLibrariesOnly: args['includeTestLibrariesOnly'] as boolean | undefined, includeTestFrameworkOnly: args['includeTestFrameworkOnly'] as boolean | undefined, enableTaskScheduler: args['enableTaskScheduler'] as boolean | undefined, assignPremiumPlan: args['assignPremiumPlan'] as boolean | undefined, multitenant: args['multitenant'] as boolean | undefined, memoryLimit: args['memoryLimit'] as string | undefined, isolation: args['isolation'] as 'hyperv' | 'process' | undefined, updateHosts: args['updateHosts'] as boolean | undefined, }; const result = await manager.createContainer(containerName, options); return { success: result.success, content: result, isError: !result.success, }; } private async handleRemoveContainer(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['containerName'], { containerName: 'string' }); const containerName = sanitizeString(args['containerName'] as string); const force = args['force'] as boolean | undefined; const manager = this.getContainerManager(); const result = await manager.removeContainer(containerName, force); return { success: result.success, content: result, isError: !result.success, }; } // ==================== Git Operations Handlers ==================== private getGitOperations(): GitOperations { if (!this.gitOperations) { const workspaceRoot = this.getWorkspaceRoot(); this.gitOperations = new GitOperations(workspaceRoot); } return this.gitOperations; } private async handleGitStatus(): Promise<ToolResult> { const git = this.getGitOperations(); if (!await git.isGitRepository()) { return { success: false, content: 'Not a git repository', isError: true }; } const status = await git.getStatus(); return { success: true, content: status }; } private async handleGitDiff(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const diff = await git.getDiff({ staged: args['staged'] as boolean | undefined, file: args['file'] as string | undefined, unified: args['unified'] as number | undefined, }); return { success: true, content: diff || '(No changes)', }; } private async handleGitStage(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['paths'], {}); const git = this.getGitOperations(); const paths = args['paths'] as string[] | 'all'; const result = await git.stage(paths); return { success: result.success, content: result, isError: !result.success, }; } private async handleGitCommit(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['message'], { message: 'string' }); const git = this.getGitOperations(); const message = args['message'] as string; const result = await git.commit(message, { amend: args['amend'] as boolean | undefined, allowEmpty: args['allowEmpty'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleGitLog(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const commits = await git.getLog({ limit: (args['limit'] as number) || 20, since: args['since'] as string | undefined, author: args['author'] as string | undefined, grep: args['grep'] as string | undefined, file: args['file'] as string | undefined, }); return { success: true, content: { count: commits.length, commits, } }; } private async handleGitBranches(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const remote = args['remote'] as boolean | undefined; const branches = await git.listBranches(remote); const currentBranch = branches.find(b => b.current); return { success: true, content: { current: currentBranch?.name, count: branches.length, branches, } }; } private async handleGitCheckout(args: Record<string, unknown>): Promise<ToolResult> { validateToolArgs(args, ['target'], { target: 'string' }); const git = this.getGitOperations(); const target = sanitizeString(args['target'] as string); const result = await git.checkout(target, { create: args['create'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleGitPull(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const result = await git.pull({ remote: args['remote'] as string | undefined, branch: args['branch'] as string | undefined, rebase: args['rebase'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleGitPush(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const result = await git.push({ remote: args['remote'] as string | undefined, branch: args['branch'] as string | undefined, setUpstream: args['setUpstream'] as boolean | undefined, force: args['force'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success, }; } private async handleGitStash(args: Record<string, unknown>): Promise<ToolResult> { const git = this.getGitOperations(); const action = (args['action'] as string) || 'list'; switch (action) { case 'save': case 'push': { const result = await git.stash({ message: args['message'] as string | undefined, includeUntracked: args['includeUntracked'] as boolean | undefined, }); return { success: result.success, content: result, isError: !result.success }; } case 'pop': { const result = await git.stashPop(args['index'] as number | undefined); return { success: result.success, content: result, isError: !result.success }; } case 'list': default: { const stashes = await git.stashList(); return { success: true, content: { count: stashes.length, stashes } }; } } } // ==================== Getting Started Handler ==================== private async handleGetStarted(): Promise<ToolResult> { const workspace = this.workspaceRoot || findALWorkspace(); const hasWorkspace = !!workspace; // Check AL Language Server status const alServerReady = this.alServer !== null; // Get cloud status let cloudConnected = false; if (this.cloudClient) { try { cloudConnected = await this.cloudClient.checkConnection(); } catch { cloudConnected = false; } } // Build the getting started response const response = { welcome: '🚀 PartnerCore AL Development - Ready to help!', status: { workspace: hasWorkspace ? `✅ ${workspace}` : '⚠️ No AL workspace detected (looking for app.json)', alLanguageServer: alServerReady ? '✅ Ready' : '⚠️ Will initialize on first AL file operation', cloudConnection: cloudConnected ? '✅ Connected (AI review, KB, templates available)' : '⚠️ Offline (local tools only)', }, workflows: { newObject: [ '1. partnercore_kb_search → Find best practices', '2. partnercore_template → Get code template', '3. write_file → Write the AL code', '4. al_get_diagnostics → Check compilation (ALWAYS!)', '5. partnercore_review → Code review', '6. git_commit → Save your work', ], codeReview: [ '1. read_file → Read the code', '2. al_get_diagnostics → Check errors', '3. partnercore_review → Get AI review', '4. al_code_actions → Get suggested fixes', '5. write_file → Apply improvements', ], bcContainer: [ '1. bc_list_containers → Check containers', '2. bc_compile → Compile app', '3. bc_publish → Deploy to container', '4. bc_run_tests → Run tests', ], git: [ '1. git_status → See changes', '2. git_diff → Review changes', '3. git_stage → Stage files', '4. git_commit → Commit', '5. git_push → Push to remote', ], refactoring: [ '1. al_get_symbols → Understand structure', '2. al_find_references → Find usages', '3. al_rename_symbol → Rename', '4. al_format → Clean up', '5. al_get_diagnostics → Verify', ], }, tools: { fileOperations: [ 'read_file - Read file contents', 'write_file - Write/create files', 'list_files - List directory contents', 'search_files - Search text in files', 'find_file - Find files by pattern', 'replace_content - Search/replace with regex', 'delete_lines - Delete line range', 'replace_lines - Replace line range', 'insert_at_line - Insert at specific line', ], alLanguageServer: [ 'al_get_diagnostics - ⭐ ALWAYS USE after writing AL code', 'al_get_symbols - Get all symbols in a file', 'al_find_symbol - Search symbols by name', 'al_find_references - Find all references to a symbol', 'al_go_to_definition - Navigate to symbol definition', 'al_hover - Get type info and documentation', 'al_completion - Get code completion suggestions', 'al_code_actions - Get quick fixes and refactorings', 'al_signature_help - Get function parameter hints', 'al_format - Format document', 'al_rename_symbol - Rename across workspace', ], bcContainers: [ 'bc_list_containers - List BC Docker containers', 'bc_create_container - Create new container', 'bc_remove_container - Remove container', 'bc_compile - Compile AL project', 'bc_publish - Publish app to container', 'bc_run_tests - Run automated tests', 'bc_download_symbols - Download symbol files', ], git: [ 'git_status - Get current status', 'git_diff - Show changes', 'git_stage - Stage files', 'git_commit - Commit changes', 'git_push - Push to remote', 'git_branches - List branches', 'git_checkout - Switch/create branches', ], projectMemory: [ 'write_memory - Save project knowledge for future sessions', 'read_memory - Retrieve saved memory', 'list_memories - List all memories', 'delete_memory - Delete a memory', ], cloud: cloudConnected ? [ 'partnercore_kb_search - Search knowledge base', 'partnercore_template - Get code templates', 'partnercore_review - AI code review', 'partnercore_validate - AppSource compliance check', ] : ['(Not connected - API_KEY required)'], }, criticalReminders: [ '⚠️ ALWAYS call al_get_diagnostics after writing any AL file', '⚠️ Use MCP tools (read_file, list_files) instead of shell commands', '⚠️ If workspace detection failed, use set_workspace to set it manually', ], nextSteps: hasWorkspace ? 'Use list_files to explore the project, or describe what you want to build.' : '⚠️ No workspace detected! Use set_workspace tool with the absolute path to your AL project (containing app.json). Example: set_workspace({ path: "C:\\\\myspace\\\\work\\\\MyProject" })', }; return { success: true, content: response }; } /** * Handle set_workspace tool - allows manual workspace configuration */ private handleSetWorkspace(args: Record<string, unknown>): ToolResult { const workspacePath = args['path'] as string | undefined; if (!workspacePath) { return { success: false, isError: true, content: 'Error: path is required. Provide the absolute path to your AL project root (containing app.json).', }; } // Resolve the path const resolved = path.resolve(workspacePath); // Check if path exists if (!fs.existsSync(resolved)) { return { success: false, isError: true, content: `Error: Path does not exist: ${resolved}`, }; } // Check if it's a directory const stats = fs.statSync(resolved); if (!stats.isDirectory()) { return { success: false, isError: true, content: `Error: Path is not a directory: ${resolved}`, }; } // Check for app.json (optional but recommended) const appJsonPath = path.join(resolved, 'app.json'); const hasAppJson = fs.existsSync(appJsonPath); // Set the workspace this.setWorkspace(resolved); // Build response const response: Record<string, unknown> = { success: true, workspace: resolved, hasAppJson, message: hasAppJson ? `✅ Workspace set to: ${resolved} (AL project detected)` : `⚠️ Workspace set to: ${resolved} (No app.json found - AL tools may not work correctly)`, }; // If there's an app.json, try to read project info if (hasAppJson) { try { const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8')) as Record<string, unknown>; response['project'] = { name: appJson['name'] as string, publisher: appJson['publisher'] as string, version: appJson['version'] as string, }; } catch { // Ignore parsing errors } } return { success: true, content: response }; } // ==================== Tool Definitions ==================== private initLocalToolDefinitions(): void { this.localToolDefinitions = [ { name: 'al_get_started', description: '🚀 START HERE - The recommended first tool to call in any AL development session. Returns: workspace status, AL Language Server status, cloud connection status, available workflows/prompts, tool categories, and a quick-start guide. Use this to understand what capabilities are available before beginning work.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'set_workspace', description: '🔧 Set the workspace root directory manually. Use this if the workspace was not auto-detected correctly or if you need to change the working directory. The path should be an absolute path to your AL project root (containing app.json).', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute path to the AL project root directory (e.g., C:\\myspace\\work\\MyProject)', }, }, required: ['path'], }, }, { name: 'al_get_symbols', description: 'Get all symbols (procedures, fields, variables, etc.) in an AL file', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI (e.g., file:///C:/project/src/MyTable.Table.al)', }, }, required: ['uri'], }, }, { name: 'al_find_symbol', description: 'Search for symbols by name across the workspace', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Symbol name to search for', }, }, required: ['query'], }, }, { name: 'al_find_references', description: 'Find all references to a symbol at a specific position', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_get_diagnostics', description: 'Get compiler diagnostics (errors, warnings) for an AL file', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, }, required: ['uri'], }, }, { name: 'al_go_to_definition', description: 'Go to the definition of a symbol at a specific position', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_hover', description: 'Get hover information (type info, documentation) for a position', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_completion', description: 'Get code completion suggestions at a position', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, // ==================== New LSP Tools ==================== { name: 'al_code_actions', description: 'Get code actions (quick fixes, refactorings) for a position or range. Returns available fixes for diagnostics and refactoring options.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based) for point query' }, character: { type: 'number', description: 'Character position (0-based) for point query' }, startLine: { type: 'number', description: 'Start line for range query' }, startCharacter: { type: 'number', description: 'Start character for range query' }, endLine: { type: 'number', description: 'End line for range query' }, endCharacter: { type: 'number', description: 'End character for range query' }, only: { type: 'array', items: { type: 'string' }, description: 'Filter actions by kind (e.g., "quickfix", "refactor")' }, includeDiagnostics: { type: 'boolean', description: 'Include diagnostics in context (default: true)' }, }, required: ['uri'], }, }, { name: 'al_signature_help', description: 'Get signature help (function parameter hints) at a position. Useful when cursor is inside function call parentheses.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based), typically after "(" or ","' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_format', description: 'Format an AL document or a range within it. Can preview or apply formatting changes.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, startLine: { type: 'number', description: 'Start line for range formatting (omit for full document)' }, startCharacter: { type: 'number', description: 'Start character for range formatting' }, endLine: { type: 'number', description: 'End line for range formatting' }, endCharacter: { type: 'number', description: 'End character for range formatting' }, tabSize: { type: 'number', description: 'Tab size (default: 4)' }, insertSpaces: { type: 'boolean', description: 'Use spaces instead of tabs (default: true)' }, apply: { type: 'boolean', description: 'Apply changes to file (default: false, preview only)' }, }, required: ['uri'], }, }, // ==================== Additional LSP Tools (Complete Coverage) ==================== { name: 'al_document_highlight', description: 'Highlight all occurrences of the symbol under cursor. Useful for seeing where a variable/function is used.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_folding_ranges', description: 'Get code folding ranges (regions, procedures, comments). Useful for understanding document structure.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, }, required: ['uri'], }, }, { name: 'al_selection_range', description: 'Get smart selection ranges for expanding/shrinking selection. Returns nested ranges from inner to outer.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, positions: { type: 'array', items: { type: 'object', properties: { line: { type: 'number' }, character: { type: 'number' }, }, }, description: 'Positions to get selection ranges for' }, }, required: ['uri', 'positions'], }, }, { name: 'al_type_definition', description: 'Go to the type definition of a variable. E.g., for "var x: Customer", goes to Customer table.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_implementation', description: 'Find implementations of an interface or abstract method.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_format_on_type', description: 'Format code after typing a specific character (e.g., semicolon, closing brace).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, ch: { type: 'string', description: 'Character that was typed (e.g., ";", "}")' }, tabSize: { type: 'number', description: 'Tab size (default: 4)' }, insertSpaces: { type: 'boolean', description: 'Use spaces instead of tabs (default: true)' }, }, required: ['uri', 'line', 'character', 'ch'], }, }, { name: 'al_code_lens', description: 'Get code lenses (inline hints like reference counts, run test buttons).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, resolve: { type: 'boolean', description: 'Resolve lens commands (default: false)' }, }, required: ['uri'], }, }, { name: 'al_document_links', description: 'Get clickable document links (URLs in comments, file references).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, }, required: ['uri'], }, }, { name: 'al_execute_command', description: 'Execute an LSP command (from code actions or code lenses).', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'Command identifier' }, arguments: { type: 'array', items: {}, description: 'Command arguments' }, }, required: ['command'], }, }, { name: 'al_semantic_tokens', description: 'Get semantic tokens for syntax highlighting. Returns token types and modifiers.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, startLine: { type: 'number', description: 'Start line for range (omit for full document)' }, startCharacter: { type: 'number', description: 'Start character for range' }, endLine: { type: 'number', description: 'End line for range' }, endCharacter: { type: 'number', description: 'End character for range' }, }, required: ['uri'], }, }, { name: 'al_close_document', description: 'Close a document in the language server (cleanup resources).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI to close' }, }, required: ['uri'], }, }, { name: 'al_save_document', description: 'Send save notification to language server (may trigger recompilation).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, text: { type: 'string', description: 'Optional: current file content' }, }, required: ['uri'], }, }, // ==================== Extended AL Tools ==================== { name: 'al_restart_server', description: 'Restart the AL Language Server. Use when the server hangs or after external changes.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'al_find_referencing_symbols', description: 'Find all symbols that reference the symbol at a position. Returns context around each reference.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, line: { type: 'number', description: 'Line number (0-based)' }, character: { type: 'number', description: 'Character position (0-based)' }, includeDeclaration: { type: 'boolean', description: 'Include the declaration itself (default: false)' }, contextLinesBefore: { type: 'number', description: 'Lines of context before reference (default: 1)' }, contextLinesAfter: { type: 'number', description: 'Lines of context after reference (default: 1)' }, }, required: ['uri', 'line', 'character'], }, }, { name: 'al_insert_before_symbol', description: 'Insert content before a named symbol (e.g., add import, decorator, or method before a class).', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, symbolName: { type: 'string', description: 'Name of the symbol to insert before' }, content: { type: 'string', description: 'Content to insert' }, }, required: ['uri', 'symbolName', 'content'], }, }, { name: 'find_file', description: 'Find files matching a pattern (supports * and ? wildcards).', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'File pattern to match (e.g., "*.Table.al", "Customer*")' }, directory: { type: 'string', description: 'Directory to search in (default: workspace root)' }, }, required: ['pattern'], }, }, { name: 'replace_content', description: 'Replace content in a file using literal string or regex. Powerful for multi-line edits.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, needle: { type: 'string', description: 'String or regex pattern to find' }, replacement: { type: 'string', description: 'Replacement text' }, mode: { type: 'string', enum: ['literal', 'regex'], description: 'Match mode (default: literal)' }, allowMultiple: { type: 'boolean', description: 'Allow replacing multiple matches (default: false)' }, }, required: ['path', 'needle', 'replacement'], }, }, { name: 'edit_memory', description: 'Edit a memory using search/replace (supports regex).', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Memory name' }, needle: { type: 'string', description: 'String or regex pattern to find' }, replacement: { type: 'string', description: 'Replacement text' }, mode: { type: 'string', enum: ['literal', 'regex'], description: 'Match mode (default: literal)' }, allowMultiple: { type: 'boolean', description: 'Allow replacing multiple matches (default: false)' }, }, required: ['name', 'needle', 'replacement'], }, }, { name: 'read_file', description: 'Read the contents of a file', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, }, required: ['path'], }, }, { name: 'write_file', description: 'Write content to a file', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, content: { type: 'string', description: 'File content' }, }, required: ['path', 'content'], }, }, { name: 'list_files', description: 'List files in a directory', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, pattern: { type: 'string', description: 'File pattern (e.g., *.al)' }, }, required: ['path'], }, }, { name: 'search_files', description: 'Search for text in files', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, query: { type: 'string', description: 'Search query' }, filePattern: { type: 'string', description: 'File pattern (default: *.al)' }, }, required: ['path', 'query'], }, }, // ==================== Symbol-Based Editing Tools ==================== { name: 'al_rename_symbol', description: 'Rename a symbol (variable, procedure, field, etc.) across the entire workspace. Uses LSP refactoring capabilities for accurate renaming.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI where the symbol is defined' }, line: { type: 'number', description: 'Line number of the symbol (0-based)' }, character: { type: 'number', description: 'Character position of the symbol (0-based)' }, newName: { type: 'string', description: 'The new name for the symbol' }, }, required: ['uri', 'line', 'character', 'newName'], }, }, { name: 'al_insert_after_symbol', description: 'Insert content after a named symbol (procedure, field, etc.). Useful for adding new procedures or fields after existing ones.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, symbolName: { type: 'string', description: 'Name of the symbol to insert after' }, content: { type: 'string', description: 'Content to insert' }, }, required: ['uri', 'symbolName', 'content'], }, }, { name: 'al_replace_symbol_body', description: 'Replace the entire body of a symbol (procedure body, trigger body, etc.). Useful for rewriting implementations.', inputSchema: { type: 'object', properties: { uri: { type: 'string', description: 'File URI' }, symbolName: { type: 'string', description: 'Name of the symbol to replace' }, newBody: { type: 'string', description: 'New body content for the symbol' }, }, required: ['uri', 'symbolName', 'newBody'], }, }, // ==================== Advanced File Operations ==================== { name: 'delete_lines', description: 'Delete a range of lines from a file. Line numbers are 1-based.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, startLine: { type: 'number', description: 'First line to delete (1-based)' }, endLine: { type: 'number', description: 'Last line to delete (1-based, inclusive)' }, }, required: ['path', 'startLine', 'endLine'], }, }, { name: 'replace_lines', description: 'Replace a range of lines in a file with new content. Line numbers are 1-based.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, startLine: { type: 'number', description: 'First line to replace (1-based)' }, endLine: { type: 'number', description: 'Last line to replace (1-based, inclusive)' }, newContent: { type: 'string', description: 'New content to insert (can be multi-line)' }, }, required: ['path', 'startLine', 'endLine', 'newContent'], }, }, { name: 'insert_at_line', description: 'Insert content at a specific line number. Existing content is pushed down. Line number is 1-based.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, line: { type: 'number', description: 'Line number to insert at (1-based)' }, content: { type: 'string', description: 'Content to insert (can be multi-line)' }, }, required: ['path', 'line', 'content'], }, }, // ==================== Project Memory Tools ==================== { name: 'write_memory', description: 'Save project-specific knowledge/memory for future reference. Memories persist across sessions and can be used to maintain context about the project.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Memory name (unique identifier)' }, content: { type: 'string', description: 'Memory content (markdown supported)' }, tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags for categorization' }, }, required: ['name', 'content'], }, }, { name: 'read_memory', description: 'Read a specific memory by name. Use list_memories first to see available memories.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Memory name to read' }, }, required: ['name'], }, }, { name: 'list_memories', description: 'List all saved project memories. Returns names, timestamps, tags, and content previews.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'delete_memory', description: 'Delete a specific memory by name.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Memory name to delete' }, }, required: ['name'], }, }, // ==================== BC Container Tools ==================== { name: 'bc_list_containers', description: 'List all Business Central Docker containers. Shows container name, image, status, and ports.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'bc_compile', description: 'Compile the AL project in a BC container. Runs all CodeCops (AppSource, UI, PerTenant). Returns app file path, errors, and warnings.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, appProjectFolder: { type: 'string', description: 'Path to AL project folder (default: workspace root)' }, outputFolder: { type: 'string', description: 'Path for output .app file (default: .output)' }, }, required: ['containerName'], }, }, { name: 'bc_publish', description: 'Publish an AL app to a BC container. Automatically finds the latest compiled .app file or uses specified path.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, appFile: { type: 'string', description: 'Path to .app file (default: latest in .output)' }, syncMode: { type: 'string', enum: ['Add', 'Clean', 'Development', 'ForceSync'], description: 'Sync mode for schema changes (default: Development)' }, skipVerification: { type: 'boolean', description: 'Skip code signing verification' }, install: { type: 'boolean', description: 'Install the app after publishing' }, }, required: ['containerName'], }, }, { name: 'bc_run_tests', description: 'Run automated tests in a BC container. Can run all tests, specific codeunit, or specific test function.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, testCodeunit: { type: 'number', description: 'Specific test codeunit ID to run' }, testFunction: { type: 'string', description: 'Specific test function name to run' }, extensionId: { type: 'string', description: 'Extension ID to filter tests' }, detailed: { type: 'boolean', description: 'Return detailed test results' }, }, required: ['containerName'], }, }, { name: 'bc_container_logs', description: 'Get logs from a BC container. Useful for debugging startup or runtime issues.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, tail: { type: 'number', description: 'Number of lines from end (default: all)' }, since: { type: 'string', description: 'Show logs since timestamp (e.g., "2h", "30m")' }, }, required: ['containerName'], }, }, { name: 'bc_start_container', description: 'Start a stopped BC container.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, }, required: ['containerName'], }, }, { name: 'bc_stop_container', description: 'Stop a running BC container.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, }, required: ['containerName'], }, }, { name: 'bc_restart_container', description: 'Restart a BC container.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, }, required: ['containerName'], }, }, { name: 'bc_download_symbols', description: 'Download symbol files (.app) from a BC container. Required for code completion and compilation.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, targetFolder: { type: 'string', description: 'Target folder for symbols (default: .alpackages)' }, }, required: ['containerName'], }, }, { name: 'bc_create_container', description: 'Create a new Business Central Docker container using BcContainerHelper. Takes ~10-30 minutes.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name for the new container' }, version: { type: 'string', description: 'BC version (e.g., "23.0", "24.1"). Default: latest' }, country: { type: 'string', description: 'Country/region (e.g., "us", "w1", "de"). Default: us' }, type: { type: 'string', enum: ['Sandbox', 'OnPrem'], description: 'Container type. Default: Sandbox' }, auth: { type: 'string', enum: ['UserPassword', 'NavUserPassword', 'Windows', 'AAD'], description: 'Authentication type' }, username: { type: 'string', description: 'Admin username (with auth)' }, password: { type: 'string', description: 'Admin password (with auth)' }, artifactUrl: { type: 'string', description: 'Direct artifact URL (overrides version/country/type)' }, licenseFile: { type: 'string', description: 'Path to license file' }, accept_eula: { type: 'boolean', description: 'Accept EULA (default: true)' }, accept_outdated: { type: 'boolean', description: 'Accept outdated images' }, includeTestToolkit: { type: 'boolean', description: 'Include test toolkit' }, includeTestLibrariesOnly: { type: 'boolean', description: 'Include only test libraries' }, includeTestFrameworkOnly: { type: 'boolean', description: 'Include only test framework' }, enableTaskScheduler: { type: 'boolean', description: 'Enable task scheduler' }, assignPremiumPlan: { type: 'boolean', description: 'Assign premium plan to admin' }, multitenant: { type: 'boolean', description: 'Create multitenant container' }, memoryLimit: { type: 'string', description: 'Memory limit (e.g., "8G")' }, isolation: { type: 'string', enum: ['hyperv', 'process'], description: 'Container isolation mode' }, updateHosts: { type: 'boolean', description: 'Update hosts file with container name' }, }, required: ['containerName'], }, }, { name: 'bc_remove_container', description: 'Remove a Business Central Docker container and clean up resources.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the container to remove' }, force: { type: 'boolean', description: 'Force remove even if running (default: false)' }, }, required: ['containerName'], }, }, { name: 'bc_get_extensions', description: 'List all installed extensions/apps in a BC container.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, }, required: ['containerName'], }, }, { name: 'bc_uninstall_app', description: 'Uninstall an app from a BC container.', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, name: { type: 'string', description: 'Name of the app to uninstall' }, publisher: { type: 'string', description: 'Publisher of the app (optional)' }, version: { type: 'string', description: 'Version of the app (optional)' }, force: { type: 'boolean', description: 'Force uninstall (default: false)' }, username: { type: 'string', description: 'Admin username (optional)' }, password: { type: 'string', description: 'Admin password (optional)' }, }, required: ['containerName', 'name'], }, }, { name: 'bc_compile_warnings', description: 'Compile AL project and return only warnings (quick check without full build output).', inputSchema: { type: 'object', properties: { containerName: { type: 'string', description: 'Name of the BC container' }, appProjectFolder: { type: 'string', description: 'Path to app project folder (default: workspace root)' }, }, required: ['containerName'], }, }, // ==================== Git Tools ==================== { name: 'git_status', description: 'Get current Git status including branch, staged/modified/untracked files, and ahead/behind counts.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'git_diff', description: 'Show Git diff of changes. Can show all changes, staged changes, or changes to a specific file.', inputSchema: { type: 'object', properties: { staged: { type: 'boolean', description: 'Show only staged changes' }, file: { type: 'string', description: 'Show diff for a specific file' }, unified: { type: 'number', description: 'Number of context lines' }, }, required: [], }, }, { name: 'git_stage', description: 'Stage files for commit. Use "all" to stage all changes.', inputSchema: { type: 'object', properties: { paths: { oneOf: [ { type: 'array', items: { type: 'string' } }, { type: 'string', enum: ['all'] } ], description: 'Files to stage, or "all" for all changes' }, }, required: ['paths'], }, }, { name: 'git_commit', description: 'Commit staged changes with a message.', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'Commit message' }, amend: { type: 'boolean', description: 'Amend the previous commit' }, allowEmpty: { type: 'boolean', description: 'Allow empty commit' }, }, required: ['message'], }, }, { name: 'git_log', description: 'Show commit history with filtering options.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of commits (default: 20)' }, since: { type: 'string', description: 'Show commits since date (e.g., "2 weeks ago")' }, author: { type: 'string', description: 'Filter by author name/email' }, grep: { type: 'string', description: 'Search commit messages' }, file: { type: 'string', description: 'Show commits for a specific file' }, }, required: [], }, }, { name: 'git_branches', description: 'List Git branches. Shows current branch, tracking info, and upstream.', inputSchema: { type: 'object', properties: { remote: { type: 'boolean', description: 'List remote branches instead of local' }, }, required: [], }, }, { name: 'git_checkout', description: 'Switch to a branch or create a new branch.', inputSchema: { type: 'object', properties: { target: { type: 'string', description: 'Branch name to switch to' }, create: { type: 'boolean', description: 'Create new branch (-b flag)' }, }, required: ['target'], }, }, { name: 'git_pull', description: 'Pull changes from remote repository.', inputSchema: { type: 'object', properties: { remote: { type: 'string', description: 'Remote name (default: origin)' }, branch: { type: 'string', description: 'Branch to pull' }, rebase: { type: 'boolean', description: 'Rebase instead of merge' }, }, required: [], }, }, { name: 'git_push', description: 'Push commits to remote repository.', inputSchema: { type: 'object', properties: { remote: { type: 'string', description: 'Remote name (default: origin)' }, branch: { type: 'string', description: 'Branch to push' }, setUpstream: { type: 'boolean', description: 'Set upstream (-u flag)' }, force: { type: 'boolean', description: 'Force push with lease' }, }, required: [], }, }, { name: 'git_stash', description: 'Manage Git stashes (save, pop, list).', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['save', 'push', 'pop', 'list'], description: 'Action to perform (default: list)' }, message: { type: 'string', description: 'Stash message (for save/push)' }, includeUntracked: { type: 'boolean', description: 'Include untracked files (for save/push)' }, index: { type: 'number', description: 'Stash index (for pop)' }, }, required: [], }, }, ]; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ciellosinc/partnercore-proxy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server