66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9+ import { readdir } from 'node:fs/promises' ;
910import path from 'node:path' ;
1011import z from 'zod' ;
11- import { McpToolContext , declareTool } from './tool-registry' ;
12+ import { AngularWorkspace } from '../../../utilities/config' ;
13+ import { assertIsError } from '../../../utilities/error' ;
14+ import { declareTool } from './tool-registry' ;
1215
1316export const LIST_PROJECTS_TOOL = declareTool ( {
1417 name : 'list_projects' ,
1518 title : 'List Angular Projects' ,
16- description :
17- 'Lists the names of all applications and libraries defined within an Angular workspace. ' +
18- 'It reads the `angular.json` configuration file to identify the projects. ' ,
19+ description : `
20+ <Purpose>
21+ Provides a comprehensive overview of all Angular workspaces and projects within a monorepo.
22+ It is essential to use this tool as a first step before performing any project-specific actions to understand the available projects,
23+ their types, and their locations.
24+ </Purpose>
25+ <Use Cases>
26+ * Finding the correct project name to use in other commands (e.g., \`ng generate component my-comp --project=my-app\`).
27+ * Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files.
28+ * Determining if a project is an \`application\` or a \`library\`.
29+ * Getting the \`selectorPrefix\` for a project before generating a new component to ensure it follows conventions.
30+ </Use Cases>
31+ <Operational Notes>
32+ * **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST**
33+ be executed from the parent directory of the \`path\` field for the relevant workspace.
34+ * **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories).
35+ Use the \`path\` of each workspace to understand its context and choose the correct project.
36+ </Operational Notes>` ,
1937 outputSchema : {
20- projects : z . array (
38+ workspaces : z . array (
2139 z . object ( {
22- name : z
23- . string ( )
24- . describe ( 'The name of the project, as defined in the `angular.json` file.' ) ,
25- type : z
26- . enum ( [ 'application' , 'library' ] )
27- . optional ( )
28- . describe ( `The type of the project, either 'application' or 'library'.` ) ,
29- root : z
30- . string ( )
31- . describe ( 'The root directory of the project, relative to the workspace root.' ) ,
32- sourceRoot : z
33- . string ( )
34- . describe (
35- `The root directory of the project's source files, relative to the workspace root.` ,
36- ) ,
37- selectorPrefix : z
38- . string ( )
39- . optional ( )
40- . describe (
41- 'The prefix to use for component selectors.' +
42- ` For example, a prefix of 'app' would result in selectors like '<app-my-component>'.` ,
43- ) ,
40+ path : z . string ( ) . describe ( 'The path to the `angular.json` file for this workspace.' ) ,
41+ projects : z . array (
42+ z . object ( {
43+ name : z
44+ . string ( )
45+ . describe ( 'The name of the project, as defined in the `angular.json` file.' ) ,
46+ type : z
47+ . enum ( [ 'application' , 'library' ] )
48+ . optional ( )
49+ . describe ( `The type of the project, either 'application' or 'library'.` ) ,
50+ root : z
51+ . string ( )
52+ . describe ( 'The root directory of the project, relative to the workspace root.' ) ,
53+ sourceRoot : z
54+ . string ( )
55+ . describe (
56+ `The root directory of the project's source files, relative to the workspace root.` ,
57+ ) ,
58+ selectorPrefix : z
59+ . string ( )
60+ . optional ( )
61+ . describe (
62+ 'The prefix to use for component selectors.' +
63+ ` For example, a prefix of 'app' would result in selectors like '<app-my-component>'.` ,
64+ ) ,
65+ } ) ,
66+ ) ,
4467 } ) ,
4568 ) ,
69+ parsingErrors : z
70+ . array (
71+ z . object ( {
72+ filePath : z . string ( ) . describe ( 'The path to the file that could not be parsed.' ) ,
73+ message : z . string ( ) . describe ( 'The error message detailing why parsing failed.' ) ,
74+ } ) ,
75+ )
76+ . optional ( )
77+ . describe ( 'A list of files that looked like workspaces but failed to parse.' ) ,
4678 } ,
4779 isReadOnly : true ,
4880 isLocalOnly : true ,
49- shouldRegister : ( context ) => ! ! context . workspace ,
5081 factory : createListProjectsHandler ,
5182} ) ;
5283
53- function createListProjectsHandler ( { workspace } : McpToolContext ) {
84+ /**
85+ * Recursively finds all 'angular.json' files in a directory, skipping 'node_modules'.
86+ * @param dir The directory to start the search from.
87+ * @returns An async generator that yields the full path of each found 'angular.json' file.
88+ */
89+ async function * findAngularJsonFiles ( dir : string ) : AsyncGenerator < string > {
90+ try {
91+ const entries = await readdir ( dir , { withFileTypes : true } ) ;
92+ for ( const entry of entries ) {
93+ const fullPath = path . join ( dir , entry . name ) ;
94+ if ( entry . isDirectory ( ) ) {
95+ if ( entry . name === 'node_modules' ) {
96+ continue ;
97+ }
98+ yield * findAngularJsonFiles ( fullPath ) ;
99+ } else if ( entry . name === 'angular.json' ) {
100+ yield fullPath ;
101+ }
102+ }
103+ } catch ( error ) {
104+ assertIsError ( error ) ;
105+ // Silently ignore errors for directories that cannot be read
106+ if ( error . code === 'EACCES' || error . code === 'EPERM' ) {
107+ return ;
108+ }
109+ throw error ;
110+ }
111+ }
112+
113+ async function createListProjectsHandler ( ) {
54114 return async ( ) => {
55- if ( ! workspace ) {
115+ const workspaces = [ ] ;
116+ const parsingErrors : { filePath : string ; message : string } [ ] = [ ] ;
117+ const seenPaths = new Set < string > ( ) ;
118+ const mcpRoot = process . cwd ( ) ;
119+
120+ for await ( const configFile of findAngularJsonFiles ( mcpRoot ) ) {
121+ try {
122+ // A workspace may be found multiple times in a monorepo
123+ const resolvedPath = path . resolve ( configFile ) ;
124+ if ( seenPaths . has ( resolvedPath ) ) {
125+ continue ;
126+ }
127+ seenPaths . add ( resolvedPath ) ;
128+
129+ const ws = await AngularWorkspace . load ( configFile ) ;
130+
131+ const projects = [ ] ;
132+ for ( const [ name , project ] of ws . projects . entries ( ) ) {
133+ projects . push ( {
134+ name,
135+ type : project . extensions [ 'projectType' ] as 'application' | 'library' | undefined ,
136+ root : project . root ,
137+ sourceRoot : project . sourceRoot ?? path . posix . join ( project . root , 'src' ) ,
138+ selectorPrefix : project . extensions [ 'prefix' ] as string ,
139+ } ) ;
140+ }
141+
142+ workspaces . push ( {
143+ path : configFile ,
144+ projects,
145+ } ) ;
146+ } catch ( error ) {
147+ let message ;
148+ if ( error instanceof Error ) {
149+ message = error . message ;
150+ } else {
151+ // For any non-Error objects thrown, use a generic message
152+ message = 'An unknown error occurred while parsing the file.' ;
153+ }
154+
155+ parsingErrors . push ( {
156+ filePath : configFile ,
157+ message,
158+ } ) ;
159+ }
160+ }
161+
162+ if ( workspaces . length === 0 && parsingErrors . length === 0 ) {
56163 return {
57164 content : [
58165 {
@@ -63,32 +170,19 @@ function createListProjectsHandler({ workspace }: McpToolContext) {
63170 ' could not be located in the current directory or any of its parent directories.' ,
64171 } ,
65172 ] ,
66- structuredContent : { projects : [ ] } ,
173+ structuredContent : { workspaces : [ ] } ,
67174 } ;
68175 }
69176
70- const projects = [ ] ;
71- // Convert to output format
72- for ( const [ name , project ] of workspace . projects . entries ( ) ) {
73- projects . push ( {
74- name,
75- type : project . extensions [ 'projectType' ] as 'application' | 'library' | undefined ,
76- root : project . root ,
77- sourceRoot : project . sourceRoot ?? path . posix . join ( project . root , 'src' ) ,
78- selectorPrefix : project . extensions [ 'prefix' ] as string ,
79- } ) ;
177+ let text = `Found ${ workspaces . length } workspace(s).\n${ JSON . stringify ( { workspaces } ) } ` ;
178+ if ( parsingErrors . length > 0 ) {
179+ text += `\n\nWarning: The following ${ parsingErrors . length } file(s) could not be parsed and were skipped:\n` ;
180+ text += parsingErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
80181 }
81182
82- // The structuredContent field is newer and may not be supported by all hosts.
83- // A text representation of the content is also provided for compatibility.
84183 return {
85- content : [
86- {
87- type : 'text' as const ,
88- text : `Projects in the Angular workspace:\n${ JSON . stringify ( projects ) } ` ,
89- } ,
90- ] ,
91- structuredContent : { projects } ,
184+ content : [ { type : 'text' as const , text } ] ,
185+ structuredContent : { workspaces, parsingErrors } ,
92186 } ;
93187 } ;
94188}
0 commit comments