66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- import { readdir } from 'node:fs/promises' ;
9+ import { readFile , readdir } from 'node:fs/promises' ;
1010import path from 'node:path' ;
1111import { fileURLToPath } from 'node:url' ;
12+ import semver from 'semver' ;
1213import z from 'zod' ;
1314import { AngularWorkspace } from '../../../utilities/config' ;
1415import { assertIsError } from '../../../utilities/error' ;
@@ -18,6 +19,12 @@ const listProjectsOutputSchema = {
1819 workspaces : z . array (
1920 z . object ( {
2021 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+ ) ,
2128 projects : z . array (
2229 z . object ( {
2330 name : z
@@ -55,6 +62,17 @@ const listProjectsOutputSchema = {
5562 )
5663 . default ( [ ] )
5764 . 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.' ) ,
5876} ;
5977
6078export const LIST_PROJECTS_TOOL = declareTool ( {
@@ -71,6 +89,7 @@ their types, and their locations.
7189* Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files.
7290* Determining if a project is an \`application\` or a \`library\`.
7391* 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.
7493</Use Cases>
7594<Operational Notes>
7695* **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST**
@@ -135,6 +154,77 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
135154 }
136155}
137156
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+
138228// Types for the structured output of the helper function.
139229type WorkspaceData = z . infer < typeof listProjectsOutputSchema . workspaces > [ number ] ;
140230type ParsingError = z . infer < typeof listProjectsOutputSchema . parsingErrors > [ number ] ;
@@ -186,7 +276,9 @@ async function createListProjectsHandler({ server }: McpToolContext) {
186276 return async ( ) => {
187277 const workspaces : WorkspaceData [ ] = [ ] ;
188278 const parsingErrors : ParsingError [ ] = [ ] ;
279+ const versioningErrors : z . infer < typeof listProjectsOutputSchema . versioningErrors > = [ ] ;
189280 const seenPaths = new Set < string > ( ) ;
281+ const versionCache = new Map < string , string | undefined > ( ) ;
190282
191283 let searchRoots : string [ ] ;
192284 const clientCapabilities = server . server . getClientCapabilities ( ) ;
@@ -201,12 +293,26 @@ async function createListProjectsHandler({ server }: McpToolContext) {
201293 for ( const root of searchRoots ) {
202294 for await ( const configFile of findAngularJsonFiles ( root ) ) {
203295 const { workspace, error } = await loadAndParseWorkspace ( configFile , seenPaths ) ;
204- if ( workspace ) {
205- workspaces . push ( workspace ) ;
206- }
207296 if ( error ) {
208297 parsingErrors . push ( error ) ;
209298 }
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+ }
210316 }
211317 }
212318
@@ -230,10 +336,14 @@ async function createListProjectsHandler({ server }: McpToolContext) {
230336 text += `\n\nWarning: The following ${ parsingErrors . length } file(s) could not be parsed and were skipped:\n` ;
231337 text += parsingErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
232338 }
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+ }
233343
234344 return {
235345 content : [ { type : 'text' as const , text } ] ,
236- structuredContent : { workspaces, parsingErrors } ,
346+ structuredContent : { workspaces, parsingErrors, versioningErrors } ,
237347 } ;
238348 } ;
239349}
0 commit comments