@@ -14,6 +14,49 @@ import { AngularWorkspace } from '../../../utilities/config';
14
14
import { assertIsError } from '../../../utilities/error' ;
15
15
import { McpToolContext , declareTool } from './tool-registry' ;
16
16
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
+
17
60
export const LIST_PROJECTS_TOOL = declareTool ( {
18
61
name : 'list_projects' ,
19
62
title : 'List Angular Projects' ,
@@ -35,139 +78,134 @@ their types, and their locations.
35
78
* **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories).
36
79
Use the \`path\` of each workspace to understand its context and choose the correct project.
37
80
</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 ,
80
82
isReadOnly : true ,
81
83
isLocalOnly : true ,
82
84
factory : createListProjectsHandler ,
83
85
} ) ;
84
86
87
+ const EXCLUDED_DIRS = new Set ( [ 'node_modules' , 'dist' , 'out' , 'coverage' ] ) ;
88
+
85
89
/**
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.
88
94
* @returns An async generator that yields the full path of each found 'angular.json' file.
89
95
*/
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
+ }
98
119
}
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 ;
102
128
}
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
+ } ) ;
103
170
}
171
+
172
+ return { workspace : { path : configFile , projects } , error : null } ;
104
173
} 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.' ;
109
179
}
110
- throw error ;
180
+
181
+ return { workspace : null , error : { filePath : configFile , message } } ;
111
182
}
112
183
}
113
184
114
185
async function createListProjectsHandler ( { server } : McpToolContext ) {
115
186
return async ( ) => {
116
- const workspaces = [ ] ;
117
- const parsingErrors : { filePath : string ; message : string } [ ] = [ ] ;
187
+ const workspaces : WorkspaceData [ ] = [ ] ;
188
+ const parsingErrors : ParsingError [ ] = [ ] ;
118
189
const seenPaths = new Set < string > ( ) ;
119
190
120
191
let searchRoots : string [ ] ;
121
192
const clientCapabilities = server . server . getClientCapabilities ( ) ;
122
193
if ( clientCapabilities ?. roots ) {
123
194
const { roots } = await server . server . listRoots ( ) ;
124
195
searchRoots = roots ?. map ( ( r ) => path . normalize ( fileURLToPath ( r . uri ) ) ) ?? [ ] ;
125
- throw new Error ( 'hi' ) ;
126
196
} else {
127
197
// Fallback to the current working directory if client does not support roots
128
198
searchRoots = [ process . cwd ( ) ] ;
129
199
}
130
200
131
201
for ( const root of searchRoots ) {
132
202
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 ) ;
171
209
}
172
210
}
173
211
}
0 commit comments