Skip to content

Commit c3dd67e

Browse files
committed
refactor(@angular/cli): use iterative search for workspace discovery in MCP list_projects tool
Improves the reliability of the `list_projects` MCP tool by refactoring its underlying workspace discovery function, `findAngularJsonFiles`. The previous recursive implementation could fail in large monorepos. This new approach uses a queue-based, concurrent traversal to prevent stack overflow errors in deep repositories and file descriptor exhaustion (`EMFILE` errors) in wide ones. Additionally, the search now excludes dot-directories and common build/cache folders to improve performance and avoid incorrect results from build artifacts.
1 parent 7b0f697 commit c3dd67e

File tree

1 file changed

+139
-101
lines changed

1 file changed

+139
-101
lines changed

packages/angular/cli/src/commands/mcp/tools/projects.ts

Lines changed: 139 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,49 @@ import { AngularWorkspace } from '../../../utilities/config';
1414
import { assertIsError } from '../../../utilities/error';
1515
import { McpToolContext, declareTool } from './tool-registry';
1616

17+
const listProjectsOutputSchema = {
18+
workspaces: z.array(
19+
z.object({
20+
path: z.string().describe('The path to the `angular.json` file for this workspace.'),
21+
projects: z.array(
22+
z.object({
23+
name: z
24+
.string()
25+
.describe('The name of the project, as defined in the `angular.json` file.'),
26+
type: z
27+
.enum(['application', 'library'])
28+
.optional()
29+
.describe(`The type of the project, either 'application' or 'library'.`),
30+
root: z
31+
.string()
32+
.describe('The root directory of the project, relative to the workspace root.'),
33+
sourceRoot: z
34+
.string()
35+
.describe(
36+
`The root directory of the project's source files, relative to the workspace root.`,
37+
),
38+
selectorPrefix: z
39+
.string()
40+
.optional()
41+
.describe(
42+
'The prefix to use for component selectors.' +
43+
` For example, a prefix of 'app' would result in selectors like '<app-my-component>'.`,
44+
),
45+
}),
46+
),
47+
}),
48+
),
49+
parsingErrors: z
50+
.array(
51+
z.object({
52+
filePath: z.string().describe('The path to the file that could not be parsed.'),
53+
message: z.string().describe('The error message detailing why parsing failed.'),
54+
}),
55+
)
56+
.default([])
57+
.describe('A list of files that looked like workspaces but failed to parse.'),
58+
};
59+
1760
export const LIST_PROJECTS_TOOL = declareTool({
1861
name: 'list_projects',
1962
title: 'List Angular Projects',
@@ -35,139 +78,134 @@ their types, and their locations.
3578
* **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories).
3679
Use the \`path\` of each workspace to understand its context and choose the correct project.
3780
</Operational Notes>`,
38-
outputSchema: {
39-
workspaces: z.array(
40-
z.object({
41-
path: z.string().describe('The path to the `angular.json` file for this workspace.'),
42-
projects: z.array(
43-
z.object({
44-
name: z
45-
.string()
46-
.describe('The name of the project, as defined in the `angular.json` file.'),
47-
type: z
48-
.enum(['application', 'library'])
49-
.optional()
50-
.describe(`The type of the project, either 'application' or 'library'.`),
51-
root: z
52-
.string()
53-
.describe('The root directory of the project, relative to the workspace root.'),
54-
sourceRoot: z
55-
.string()
56-
.describe(
57-
`The root directory of the project's source files, relative to the workspace root.`,
58-
),
59-
selectorPrefix: z
60-
.string()
61-
.optional()
62-
.describe(
63-
'The prefix to use for component selectors.' +
64-
` For example, a prefix of 'app' would result in selectors like '<app-my-component>'.`,
65-
),
66-
}),
67-
),
68-
}),
69-
),
70-
parsingErrors: z
71-
.array(
72-
z.object({
73-
filePath: z.string().describe('The path to the file that could not be parsed.'),
74-
message: z.string().describe('The error message detailing why parsing failed.'),
75-
}),
76-
)
77-
.optional()
78-
.describe('A list of files that looked like workspaces but failed to parse.'),
79-
},
81+
outputSchema: listProjectsOutputSchema,
8082
isReadOnly: true,
8183
isLocalOnly: true,
8284
factory: createListProjectsHandler,
8385
});
8486

87+
const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']);
88+
8589
/**
86-
* Recursively finds all 'angular.json' files in a directory, skipping 'node_modules'.
87-
* @param dir The directory to start the search from.
90+
* Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions.
91+
* This non-recursive implementation is suitable for very large directory trees
92+
* and prevents file descriptor exhaustion (`EMFILE` errors).
93+
* @param rootDir The directory to start the search from.
8894
* @returns An async generator that yields the full path of each found 'angular.json' file.
8995
*/
90-
async function* findAngularJsonFiles(dir: string): AsyncGenerator<string> {
91-
try {
92-
const entries = await readdir(dir, { withFileTypes: true });
93-
for (const entry of entries) {
94-
const fullPath = path.join(dir, entry.name);
95-
if (entry.isDirectory()) {
96-
if (entry.name === 'node_modules') {
97-
continue;
96+
async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
97+
const CONCURRENCY_LIMIT = 50;
98+
const queue: string[] = [rootDir];
99+
100+
while (queue.length > 0) {
101+
const batch = queue.splice(0, CONCURRENCY_LIMIT);
102+
const foundFilesInBatch: string[] = [];
103+
104+
const promises = batch.map(async (dir) => {
105+
try {
106+
const entries = await readdir(dir, { withFileTypes: true });
107+
const subdirectories: string[] = [];
108+
for (const entry of entries) {
109+
const fullPath = path.join(dir, entry.name);
110+
if (entry.isDirectory()) {
111+
// Exclude dot-directories, build/cache directories, and node_modules
112+
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) {
113+
continue;
114+
}
115+
subdirectories.push(fullPath);
116+
} else if (entry.name === 'angular.json') {
117+
foundFilesInBatch.push(fullPath);
118+
}
98119
}
99-
yield* findAngularJsonFiles(fullPath);
100-
} else if (entry.name === 'angular.json') {
101-
yield fullPath;
120+
121+
return subdirectories;
122+
} catch (error) {
123+
assertIsError(error);
124+
if (error.code === 'EACCES' || error.code === 'EPERM') {
125+
return []; // Silently ignore permission errors.
126+
}
127+
throw error;
102128
}
129+
});
130+
131+
const nestedSubdirs = await Promise.all(promises);
132+
queue.push(...nestedSubdirs.flat());
133+
134+
yield* foundFilesInBatch;
135+
}
136+
}
137+
138+
// Types for the structured output of the helper function.
139+
type WorkspaceData = z.infer<typeof listProjectsOutputSchema.workspaces>[number];
140+
type ParsingError = z.infer<typeof listProjectsOutputSchema.parsingErrors>[number];
141+
142+
/**
143+
* Loads, parses, and transforms a single angular.json file into the tool's output format.
144+
* It checks a set of seen paths to avoid processing the same workspace multiple times.
145+
* @param configFile The path to the angular.json file.
146+
* @param seenPaths A Set of absolute paths that have already been processed.
147+
* @returns A promise resolving to the workspace data or a parsing error.
148+
*/
149+
async function loadAndParseWorkspace(
150+
configFile: string,
151+
seenPaths: Set<string>,
152+
): Promise<{ workspace: WorkspaceData | null; error: ParsingError | null }> {
153+
try {
154+
const resolvedPath = path.resolve(configFile);
155+
if (seenPaths.has(resolvedPath)) {
156+
return { workspace: null, error: null }; // Already processed, skip.
157+
}
158+
seenPaths.add(resolvedPath);
159+
160+
const ws = await AngularWorkspace.load(configFile);
161+
const projects = [];
162+
for (const [name, project] of ws.projects.entries()) {
163+
projects.push({
164+
name,
165+
type: project.extensions['projectType'] as 'application' | 'library' | undefined,
166+
root: project.root,
167+
sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'),
168+
selectorPrefix: project.extensions['prefix'] as string,
169+
});
103170
}
171+
172+
return { workspace: { path: configFile, projects }, error: null };
104173
} catch (error) {
105-
assertIsError(error);
106-
// Silently ignore errors for directories that cannot be read
107-
if (error.code === 'EACCES' || error.code === 'EPERM') {
108-
return;
174+
let message;
175+
if (error instanceof Error) {
176+
message = error.message;
177+
} else {
178+
message = 'An unknown error occurred while parsing the file.';
109179
}
110-
throw error;
180+
181+
return { workspace: null, error: { filePath: configFile, message } };
111182
}
112183
}
113184

114185
async function createListProjectsHandler({ server }: McpToolContext) {
115186
return async () => {
116-
const workspaces = [];
117-
const parsingErrors: { filePath: string; message: string }[] = [];
187+
const workspaces: WorkspaceData[] = [];
188+
const parsingErrors: ParsingError[] = [];
118189
const seenPaths = new Set<string>();
119190

120191
let searchRoots: string[];
121192
const clientCapabilities = server.server.getClientCapabilities();
122193
if (clientCapabilities?.roots) {
123194
const { roots } = await server.server.listRoots();
124195
searchRoots = roots?.map((r) => path.normalize(fileURLToPath(r.uri))) ?? [];
125-
throw new Error('hi');
126196
} else {
127197
// Fallback to the current working directory if client does not support roots
128198
searchRoots = [process.cwd()];
129199
}
130200

131201
for (const root of searchRoots) {
132202
for await (const configFile of findAngularJsonFiles(root)) {
133-
try {
134-
// A workspace may be found multiple times in a monorepo
135-
const resolvedPath = path.resolve(configFile);
136-
if (seenPaths.has(resolvedPath)) {
137-
continue;
138-
}
139-
seenPaths.add(resolvedPath);
140-
141-
const ws = await AngularWorkspace.load(configFile);
142-
143-
const projects = [];
144-
for (const [name, project] of ws.projects.entries()) {
145-
projects.push({
146-
name,
147-
type: project.extensions['projectType'] as 'application' | 'library' | undefined,
148-
root: project.root,
149-
sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'),
150-
selectorPrefix: project.extensions['prefix'] as string,
151-
});
152-
}
153-
154-
workspaces.push({
155-
path: configFile,
156-
projects,
157-
});
158-
} catch (error) {
159-
let message;
160-
if (error instanceof Error) {
161-
message = error.message;
162-
} else {
163-
// For any non-Error objects thrown, use a generic message
164-
message = 'An unknown error occurred while parsing the file.';
165-
}
166-
167-
parsingErrors.push({
168-
filePath: configFile,
169-
message,
170-
});
203+
const { workspace, error } = await loadAndParseWorkspace(configFile, seenPaths);
204+
if (workspace) {
205+
workspaces.push(workspace);
206+
}
207+
if (error) {
208+
parsingErrors.push(error);
171209
}
172210
}
173211
}

0 commit comments

Comments
 (0)