/**
* 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: [],
},
},
];
}
}