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' ;
11+ import { fileURLToPath } from 'node:url' ;
1012import z from 'zod' ;
13+ import { AngularWorkspace } from '../../../utilities/config' ;
14+ import { assertIsError } from '../../../utilities/error' ;
1115import { McpToolContext , declareTool } from './tool-registry' ;
1216
1317export const LIST_PROJECTS_TOOL = declareTool ( {
1418 name : 'list_projects' ,
1519 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. ' ,
20+ description : `
21+ <Purpose>
22+ Provides a comprehensive overview of all Angular workspaces and projects within a monorepo.
23+ It is essential to use this tool as a first step before performing any project-specific actions to understand the available projects,
24+ their types, and their locations.
25+ </Purpose>
26+ <Use Cases>
27+ * Finding the correct project name to use in other commands (e.g., \`ng generate component my-comp --project=my-app\`).
28+ * Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files.
29+ * Determining if a project is an \`application\` or a \`library\`.
30+ * Getting the \`selectorPrefix\` for a project before generating a new component to ensure it follows conventions.
31+ </Use Cases>
32+ <Operational Notes>
33+ * **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST**
34+ be executed from the parent directory of the \`path\` field for the relevant workspace.
35+ * **Disambiguation:** A monorepo may contain multiple workspaces (e.g., for different applications or even in output directories).
36+ Use the \`path\` of each workspace to understand its context and choose the correct project.
37+ </Operational Notes>` ,
1938 outputSchema : {
20- projects : z . array (
39+ workspaces : z . array (
2140 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- ) ,
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+ ) ,
4468 } ) ,
4569 ) ,
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.' ) ,
4679 } ,
4780 isReadOnly : true ,
4881 isLocalOnly : true ,
49- shouldRegister : ( context ) => ! ! context . workspace ,
5082 factory : createListProjectsHandler ,
5183} ) ;
5284
53- function createListProjectsHandler ( { workspace } : McpToolContext ) {
85+ /**
86+ * Recursively finds all 'angular.json' files in a directory, skipping 'node_modules'.
87+ * @param dir The directory to start the search from.
88+ * @returns An async generator that yields the full path of each found 'angular.json' file.
89+ */
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 ;
98+ }
99+ yield * findAngularJsonFiles ( fullPath ) ;
100+ } else if ( entry . name === 'angular.json' ) {
101+ yield fullPath ;
102+ }
103+ }
104+ } 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 ;
109+ }
110+ throw error ;
111+ }
112+ }
113+
114+ async function createListProjectsHandler ( { server } : McpToolContext ) {
54115 return async ( ) => {
55- if ( ! workspace ) {
116+ const workspaces = [ ] ;
117+ const parsingErrors : { filePath : string ; message : string } [ ] = [ ] ;
118+ const seenPaths = new Set < string > ( ) ;
119+
120+ let searchRoots : string [ ] ;
121+ const clientCapabilities = server . server . getClientCapabilities ( ) ;
122+ if ( clientCapabilities ?. roots ) {
123+ const { roots } = await server . server . listRoots ( ) ;
124+ searchRoots = roots ?. map ( ( r ) => path . normalize ( fileURLToPath ( r . uri ) ) ) ?? [ ] ;
125+ throw new Error ( 'hi' ) ;
126+ } else {
127+ // Fallback to the current working directory if client does not support roots
128+ searchRoots = [ process . cwd ( ) ] ;
129+ }
130+
131+ for ( const root of searchRoots ) {
132+ 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+ } ) ;
171+ }
172+ }
173+ }
174+
175+ if ( workspaces . length === 0 && parsingErrors . length === 0 ) {
56176 return {
57177 content : [
58178 {
@@ -63,32 +183,19 @@ function createListProjectsHandler({ workspace }: McpToolContext) {
63183 ' could not be located in the current directory or any of its parent directories.' ,
64184 } ,
65185 ] ,
66- structuredContent : { projects : [ ] } ,
186+ structuredContent : { workspaces : [ ] } ,
67187 } ;
68188 }
69189
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- } ) ;
190+ let text = `Found ${ workspaces . length } workspace(s).\n${ JSON . stringify ( { workspaces } ) } ` ;
191+ if ( parsingErrors . length > 0 ) {
192+ text += `\n\nWarning: The following ${ parsingErrors . length } file(s) could not be parsed and were skipped:\n` ;
193+ text += parsingErrors . map ( ( e ) => `- ${ e . filePath } : ${ e . message } ` ) . join ( '\n' ) ;
80194 }
81195
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.
84196 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 } ,
197+ content : [ { type : 'text' as const , text } ] ,
198+ structuredContent : { workspaces, parsingErrors } ,
92199 } ;
93200 } ;
94201}
0 commit comments