6
6
* found in the LICENSE file at https://angular.dev/license
7
7
*/
8
8
9
- import { readdir } from 'node:fs/promises' ;
9
+ import { readFile , readdir } from 'node:fs/promises' ;
10
10
import path from 'node:path' ;
11
11
import { fileURLToPath } from 'node:url' ;
12
+ import semver from 'semver' ;
12
13
import z from 'zod' ;
13
14
import { AngularWorkspace } from '../../../utilities/config' ;
14
15
import { assertIsError } from '../../../utilities/error' ;
@@ -18,6 +19,12 @@ const listProjectsOutputSchema = {
18
19
workspaces : z . array (
19
20
z . object ( {
20
21
path : z . string ( ) . describe ( 'The path to the `angular.json` file for this workspace.' ) ,
22
+ frameworkVersion : z
23
+ . string ( )
24
+ . optional ( )
25
+ . describe (
26
+ 'The major version of the Angular framework (`@angular/core`) in this workspace, if found.' ,
27
+ ) ,
21
28
projects : z . array (
22
29
z . object ( {
23
30
name : z
@@ -55,6 +62,17 @@ const listProjectsOutputSchema = {
55
62
)
56
63
. default ( [ ] )
57
64
. describe ( 'A list of files that looked like workspaces but failed to parse.' ) ,
65
+ versioningErrors : z
66
+ . array (
67
+ z . object ( {
68
+ filePath : z
69
+ . string ( )
70
+ . describe ( 'The path to the workspace `angular.json` for which versioning failed.' ) ,
71
+ message : z . string ( ) . describe ( 'The error message detailing why versioning failed.' ) ,
72
+ } ) ,
73
+ )
74
+ . default ( [ ] )
75
+ . describe ( 'A list of workspaces for which the framework version could not be determined.' ) ,
58
76
} ;
59
77
60
78
export const LIST_PROJECTS_TOOL = declareTool ( {
@@ -71,6 +89,7 @@ their types, and their locations.
71
89
* Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files.
72
90
* Determining if a project is an \`application\` or a \`library\`.
73
91
* Getting the \`selectorPrefix\` for a project before generating a new component to ensure it follows conventions.
92
+ * Identifying the major version of the Angular framework for each workspace, which is crucial for monorepos.
74
93
</Use Cases>
75
94
<Operational Notes>
76
95
* **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST**
@@ -135,6 +154,77 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
135
154
}
136
155
}
137
156
157
+ /**
158
+ * Searches upwards from a starting directory to find the version of '@angular/core'.
159
+ * It caches results to avoid redundant lookups.
160
+ * @param startDir The directory to start the search from.
161
+ * @param cache A map to store cached results.
162
+ * @param searchRoot The directory at which to stop the search.
163
+ * @returns The major version of '@angular/core' as a string, otherwise undefined.
164
+ */
165
+ async function findAngularCoreVersion (
166
+ startDir : string ,
167
+ cache : Map < string , string | undefined > ,
168
+ searchRoot : string ,
169
+ ) : Promise < string | undefined > {
170
+ let currentDir = startDir ;
171
+ const dirsToCache : string [ ] = [ ] ;
172
+
173
+ while ( currentDir ) {
174
+ dirsToCache . push ( currentDir ) ;
175
+ if ( cache . has ( currentDir ) ) {
176
+ const cachedResult = cache . get ( currentDir ) ;
177
+ // Populate cache for all intermediate directories.
178
+ for ( const dir of dirsToCache ) {
179
+ cache . set ( dir , cachedResult ) ;
180
+ }
181
+
182
+ return cachedResult ;
183
+ }
184
+
185
+ const pkgPath = path . join ( currentDir , 'package.json' ) ;
186
+ try {
187
+ const pkgContent = await readFile ( pkgPath , 'utf-8' ) ;
188
+ const pkg = JSON . parse ( pkgContent ) ;
189
+ const versionSpecifier =
190
+ pkg . dependencies ?. [ '@angular/core' ] ?? pkg . devDependencies ?. [ '@angular/core' ] ;
191
+
192
+ if ( versionSpecifier ) {
193
+ const minVersion = semver . minVersion ( versionSpecifier ) ;
194
+ const result = minVersion ? String ( minVersion . major ) : undefined ;
195
+ for ( const dir of dirsToCache ) {
196
+ cache . set ( dir , result ) ;
197
+ }
198
+
199
+ return result ;
200
+ }
201
+ } catch ( error ) {
202
+ assertIsError ( error ) ;
203
+ if ( error . code !== 'ENOENT' ) {
204
+ // Ignore missing package.json files, but rethrow other errors.
205
+ throw error ;
206
+ }
207
+ }
208
+
209
+ // Stop if we are at the search root or the filesystem root.
210
+ if ( currentDir === searchRoot ) {
211
+ break ;
212
+ }
213
+ const parentDir = path . dirname ( currentDir ) ;
214
+ if ( parentDir === currentDir ) {
215
+ break ; // Reached the filesystem root.
216
+ }
217
+ currentDir = parentDir ;
218
+ }
219
+
220
+ // Cache the failure for all traversed directories.
221
+ for ( const dir of dirsToCache ) {
222
+ cache . set ( dir , undefined ) ;
223
+ }
224
+
225
+ return undefined ;
226
+ }
227
+
138
228
// Types for the structured output of the helper function.
139
229
type WorkspaceData = z . infer < typeof listProjectsOutputSchema . workspaces > [ number ] ;
140
230
type ParsingError = z . infer < typeof listProjectsOutputSchema . parsingErrors > [ number ] ;
@@ -186,7 +276,9 @@ async function createListProjectsHandler({ server }: McpToolContext) {
186
276
return async ( ) => {
187
277
const workspaces : WorkspaceData [ ] = [ ] ;
188
278
const parsingErrors : ParsingError [ ] = [ ] ;
279
+ const versioningErrors : z . infer < typeof listProjectsOutputSchema . versioningErrors > = [ ] ;
189
280
const seenPaths = new Set < string > ( ) ;
281
+ const versionCache = new Map < string , string | undefined > ( ) ;
190
282
191
283
let searchRoots : string [ ] ;
192
284
const clientCapabilities = server . server . getClientCapabilities ( ) ;
@@ -201,12 +293,26 @@ async function createListProjectsHandler({ server }: McpToolContext) {
201
293
for ( const root of searchRoots ) {
202
294
for await ( const configFile of findAngularJsonFiles ( root ) ) {
203
295
const { workspace, error } = await loadAndParseWorkspace ( configFile , seenPaths ) ;
204
- if ( workspace ) {
205
- workspaces . push ( workspace ) ;
206
- }
207
296
if ( error ) {
208
297
parsingErrors . push ( error ) ;
209
298
}
299
+
300
+ if ( workspace ) {
301
+ try {
302
+ const workspaceDir = path . dirname ( configFile ) ;
303
+ workspace . frameworkVersion = await findAngularCoreVersion (
304
+ workspaceDir ,
305
+ versionCache ,
306
+ root ,
307
+ ) ;
308
+ } catch ( e ) {
309
+ versioningErrors . push ( {
310
+ filePath : workspace . path ,
311
+ message : e instanceof Error ? e . message : 'An unknown error occurred.' ,
312
+ } ) ;
313
+ }
314
+ workspaces . push ( workspace ) ;
315
+ }
210
316
}
211
317
}
212
318
@@ -230,10 +336,14 @@ async function createListProjectsHandler({ server }: McpToolContext) {
230
336
text += `\n\nWarning: The following ${ parsingErrors . length } file(s) could not be parsed and were skipped:\n` ;
231
337
text += parsingErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
232
338
}
339
+ if ( versioningErrors . length > 0 ) {
340
+ text += `\n\nWarning: The framework version for the following ${ versioningErrors . length } workspace(s) could not be determined:\n` ;
341
+ text += versioningErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
342
+ }
233
343
234
344
return {
235
345
content : [ { type : 'text' as const , text } ] ,
236
- structuredContent : { workspaces, parsingErrors } ,
346
+ structuredContent : { workspaces, parsingErrors, versioningErrors } ,
237
347
} ;
238
348
} ;
239
349
}
0 commit comments