Skip to content

Commit 308c47a

Browse files
committed
fix(@angular/cli): improve list_projects MCP tool to find all workspaces in monorepos
The `list_projects` MCP tool is enhanced with better monorepo support by correctly discovering all `angular.json` files in any subdirectory. The tool's description is also rewritten to follow best practices for LLM consumption, using structured tags like `<Purpose>`, `<Use Cases>`, and `<Operational Notes>` to provide clear and actionable guidance.
1 parent 2c498d2 commit 308c47a

2 files changed

Lines changed: 145 additions & 51 deletions

File tree

packages/angular/cli/src/commands/mcp/tools/projects.ts

Lines changed: 144 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,160 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { readdir } from 'node:fs/promises';
910
import path from 'node:path';
1011
import 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

1316
export 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
}

tests/legacy-cli/e2e/tests/mcp/registers-tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export default async function () {
4040

4141
const { stdout: stdoutOutsideWorkspace } = await runInspector('--method', 'tools/list');
4242

43-
assert.doesNotMatch(stdoutOutsideWorkspace, /"list_projects"/);
43+
assert.match(stdoutOutsideWorkspace, /"list_projects"/);
4444
assert.match(stdoutOutsideWorkspace, /"get_best_practices"/);
4545
assert.match(stdoutInsideWorkspace, /"search_documentation"/);
4646
} finally {

0 commit comments

Comments
 (0)