6
6
* found in the LICENSE file at https://angular.dev/license
7
7
*/
8
8
9
+ import { readdir } from 'node:fs/promises' ;
9
10
import path from 'node:path' ;
11
+ import { fileURLToPath } from 'node:url' ;
10
12
import z from 'zod' ;
13
+ import { AngularWorkspace } from '../../../utilities/config' ;
14
+ import { assertIsError } from '../../../utilities/error' ;
11
15
import { McpToolContext , declareTool } from './tool-registry' ;
12
16
13
17
export const LIST_PROJECTS_TOOL = declareTool ( {
14
18
name : 'list_projects' ,
15
19
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>` ,
19
38
outputSchema : {
20
- projects : z . array (
39
+ workspaces : z . array (
21
40
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
+ ) ,
44
68
} ) ,
45
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.' ) ,
46
79
} ,
47
80
isReadOnly : true ,
48
81
isLocalOnly : true ,
49
- shouldRegister : ( context ) => ! ! context . workspace ,
50
82
factory : createListProjectsHandler ,
51
83
} ) ;
52
84
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 ) {
54
115
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 ) {
56
176
return {
57
177
content : [
58
178
{
@@ -63,32 +183,19 @@ function createListProjectsHandler({ workspace }: McpToolContext) {
63
183
' could not be located in the current directory or any of its parent directories.' ,
64
184
} ,
65
185
] ,
66
- structuredContent : { projects : [ ] } ,
186
+ structuredContent : { workspaces : [ ] } ,
67
187
} ;
68
188
}
69
189
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' ) ;
80
194
}
81
195
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.
84
196
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 } ,
92
199
} ;
93
200
} ;
94
201
}
0 commit comments