diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index 9a3ef966cfc4..9ebac541b5ca 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -14,6 +14,49 @@ import { AngularWorkspace } from '../../../utilities/config'; import { assertIsError } from '../../../utilities/error'; import { McpToolContext, declareTool } from './tool-registry'; +const listProjectsOutputSchema = { + workspaces: z.array( + z.object({ + path: z.string().describe('The path to the `angular.json` file for this workspace.'), + projects: z.array( + z.object({ + name: z + .string() + .describe('The name of the project, as defined in the `angular.json` file.'), + type: z + .enum(['application', 'library']) + .optional() + .describe(`The type of the project, either 'application' or 'library'.`), + root: z + .string() + .describe('The root directory of the project, relative to the workspace root.'), + sourceRoot: z + .string() + .describe( + `The root directory of the project's source files, relative to the workspace root.`, + ), + selectorPrefix: z + .string() + .optional() + .describe( + 'The prefix to use for component selectors.' + + ` For example, a prefix of 'app' would result in selectors like ''.`, + ), + }), + ), + }), + ), + parsingErrors: z + .array( + z.object({ + filePath: z.string().describe('The path to the file that could not be parsed.'), + message: z.string().describe('The error message detailing why parsing failed.'), + }), + ) + .default([]) + .describe('A list of files that looked like workspaces but failed to parse.'), +}; + export const LIST_PROJECTS_TOOL = declareTool({ name: 'list_projects', title: 'List Angular Projects', @@ -35,86 +78,114 @@ their types, and their locations. * **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories). Use the \`path\` of each workspace to understand its context and choose the correct project. `, - outputSchema: { - workspaces: z.array( - z.object({ - path: z.string().describe('The path to the `angular.json` file for this workspace.'), - projects: z.array( - z.object({ - name: z - .string() - .describe('The name of the project, as defined in the `angular.json` file.'), - type: z - .enum(['application', 'library']) - .optional() - .describe(`The type of the project, either 'application' or 'library'.`), - root: z - .string() - .describe('The root directory of the project, relative to the workspace root.'), - sourceRoot: z - .string() - .describe( - `The root directory of the project's source files, relative to the workspace root.`, - ), - selectorPrefix: z - .string() - .optional() - .describe( - 'The prefix to use for component selectors.' + - ` For example, a prefix of 'app' would result in selectors like ''.`, - ), - }), - ), - }), - ), - parsingErrors: z - .array( - z.object({ - filePath: z.string().describe('The path to the file that could not be parsed.'), - message: z.string().describe('The error message detailing why parsing failed.'), - }), - ) - .optional() - .describe('A list of files that looked like workspaces but failed to parse.'), - }, + outputSchema: listProjectsOutputSchema, isReadOnly: true, isLocalOnly: true, factory: createListProjectsHandler, }); +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']); + /** - * Recursively finds all 'angular.json' files in a directory, skipping 'node_modules'. - * @param dir The directory to start the search from. + * Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions. + * This non-recursive implementation is suitable for very large directory trees + * and prevents file descriptor exhaustion (`EMFILE` errors). + * @param rootDir The directory to start the search from. * @returns An async generator that yields the full path of each found 'angular.json' file. */ -async function* findAngularJsonFiles(dir: string): AsyncGenerator { - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === 'node_modules') { - continue; +async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { + const CONCURRENCY_LIMIT = 50; + const queue: string[] = [rootDir]; + + while (queue.length > 0) { + const batch = queue.splice(0, CONCURRENCY_LIMIT); + const foundFilesInBatch: string[] = []; + + const promises = batch.map(async (dir) => { + try { + const entries = await readdir(dir, { withFileTypes: true }); + const subdirectories: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Exclude dot-directories, build/cache directories, and node_modules + if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) { + continue; + } + subdirectories.push(fullPath); + } else if (entry.name === 'angular.json') { + foundFilesInBatch.push(fullPath); + } } - yield* findAngularJsonFiles(fullPath); - } else if (entry.name === 'angular.json') { - yield fullPath; + + return subdirectories; + } catch (error) { + assertIsError(error); + if (error.code === 'EACCES' || error.code === 'EPERM') { + return []; // Silently ignore permission errors. + } + throw error; } + }); + + const nestedSubdirs = await Promise.all(promises); + queue.push(...nestedSubdirs.flat()); + + yield* foundFilesInBatch; + } +} + +// Types for the structured output of the helper function. +type WorkspaceData = z.infer[number]; +type ParsingError = z.infer[number]; + +/** + * Loads, parses, and transforms a single angular.json file into the tool's output format. + * It checks a set of seen paths to avoid processing the same workspace multiple times. + * @param configFile The path to the angular.json file. + * @param seenPaths A Set of absolute paths that have already been processed. + * @returns A promise resolving to the workspace data or a parsing error. + */ +async function loadAndParseWorkspace( + configFile: string, + seenPaths: Set, +): Promise<{ workspace: WorkspaceData | null; error: ParsingError | null }> { + try { + const resolvedPath = path.resolve(configFile); + if (seenPaths.has(resolvedPath)) { + return { workspace: null, error: null }; // Already processed, skip. + } + seenPaths.add(resolvedPath); + + const ws = await AngularWorkspace.load(configFile); + const projects = []; + for (const [name, project] of ws.projects.entries()) { + projects.push({ + name, + type: project.extensions['projectType'] as 'application' | 'library' | undefined, + root: project.root, + sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'), + selectorPrefix: project.extensions['prefix'] as string, + }); } + + return { workspace: { path: configFile, projects }, error: null }; } catch (error) { - assertIsError(error); - // Silently ignore errors for directories that cannot be read - if (error.code === 'EACCES' || error.code === 'EPERM') { - return; + let message; + if (error instanceof Error) { + message = error.message; + } else { + message = 'An unknown error occurred while parsing the file.'; } - throw error; + + return { workspace: null, error: { filePath: configFile, message } }; } } async function createListProjectsHandler({ server }: McpToolContext) { return async () => { - const workspaces = []; - const parsingErrors: { filePath: string; message: string }[] = []; + const workspaces: WorkspaceData[] = []; + const parsingErrors: ParsingError[] = []; const seenPaths = new Set(); let searchRoots: string[]; @@ -122,7 +193,6 @@ async function createListProjectsHandler({ server }: McpToolContext) { if (clientCapabilities?.roots) { const { roots } = await server.server.listRoots(); searchRoots = roots?.map((r) => path.normalize(fileURLToPath(r.uri))) ?? []; - throw new Error('hi'); } else { // Fallback to the current working directory if client does not support roots searchRoots = [process.cwd()]; @@ -130,44 +200,12 @@ async function createListProjectsHandler({ server }: McpToolContext) { for (const root of searchRoots) { for await (const configFile of findAngularJsonFiles(root)) { - try { - // A workspace may be found multiple times in a monorepo - const resolvedPath = path.resolve(configFile); - if (seenPaths.has(resolvedPath)) { - continue; - } - seenPaths.add(resolvedPath); - - const ws = await AngularWorkspace.load(configFile); - - const projects = []; - for (const [name, project] of ws.projects.entries()) { - projects.push({ - name, - type: project.extensions['projectType'] as 'application' | 'library' | undefined, - root: project.root, - sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'), - selectorPrefix: project.extensions['prefix'] as string, - }); - } - - workspaces.push({ - path: configFile, - projects, - }); - } catch (error) { - let message; - if (error instanceof Error) { - message = error.message; - } else { - // For any non-Error objects thrown, use a generic message - message = 'An unknown error occurred while parsing the file.'; - } - - parsingErrors.push({ - filePath: configFile, - message, - }); + const { workspace, error } = await loadAndParseWorkspace(configFile, seenPaths); + if (workspace) { + workspaces.push(workspace); + } + if (error) { + parsingErrors.push(error); } } }