@@ -14,6 +14,49 @@ import { AngularWorkspace } from '../../../utilities/config';
1414import { assertIsError } from '../../../utilities/error' ;
1515import { 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+
1760export 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
114185async 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