Skip to content

Commit e90b5f9

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 e90b5f9

File tree

2 files changed

+114
-46
lines changed

2 files changed

+114
-46
lines changed

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

Lines changed: 113 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,133 @@
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
),
4669
},
4770
isReadOnly: true,
4871
isLocalOnly: true,
49-
shouldRegister: (context) => !!context.workspace,
5072
factory: createListProjectsHandler,
5173
});
5274

53-
function createListProjectsHandler({ workspace }: McpToolContext) {
75+
/**
76+
* Recursively finds all 'angular.json' files in a directory, skipping 'node_modules'.
77+
* @param dir The directory to start the search from.
78+
* @returns An async generator that yields the full path of each found 'angular.json' file.
79+
*/
80+
async function* findAngularJsonFiles(dir: string): AsyncGenerator<string> {
81+
try {
82+
const entries = await readdir(dir, { withFileTypes: true });
83+
for (const entry of entries) {
84+
const fullPath = path.join(dir, entry.name);
85+
if (entry.isDirectory()) {
86+
if (entry.name === 'node_modules') {
87+
continue;
88+
}
89+
yield* findAngularJsonFiles(fullPath);
90+
} else if (entry.name === 'angular.json') {
91+
yield fullPath;
92+
}
93+
}
94+
} catch (error) {
95+
assertIsError(error);
96+
// Silently ignore errors for directories that cannot be read
97+
if (error.code === 'EACCES' || error.code === 'EPERM') {
98+
return;
99+
}
100+
throw error;
101+
}
102+
}
103+
104+
async function createListProjectsHandler() {
54105
return async () => {
55-
if (!workspace) {
106+
const workspaces = [];
107+
const seenPaths = new Set<string>();
108+
for await (const configFile of findAngularJsonFiles(process.cwd())) {
109+
// A workspace may be found multiple times in a monorepo
110+
const resolvedPath = path.resolve(configFile);
111+
if (seenPaths.has(resolvedPath)) {
112+
continue;
113+
}
114+
seenPaths.add(resolvedPath);
115+
116+
const ws = await AngularWorkspace.load(configFile);
117+
118+
const projects = [];
119+
for (const [name, project] of ws.projects.entries()) {
120+
projects.push({
121+
name,
122+
type: project.extensions['projectType'] as 'application' | 'library' | undefined,
123+
root: project.root,
124+
sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'),
125+
selectorPrefix: project.extensions['prefix'] as string,
126+
});
127+
}
128+
129+
workspaces.push({
130+
path: configFile,
131+
projects,
132+
});
133+
}
134+
135+
if (workspaces.length === 0) {
56136
return {
57137
content: [
58138
{
@@ -63,32 +143,20 @@ function createListProjectsHandler({ workspace }: McpToolContext) {
63143
' could not be located in the current directory or any of its parent directories.',
64144
},
65145
],
66-
structuredContent: { projects: [] },
146+
structuredContent: { workspaces: [] },
67147
};
68148
}
69149

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-
});
80-
}
81-
82150
// The structuredContent field is newer and may not be supported by all hosts.
83151
// A text representation of the content is also provided for compatibility.
84152
return {
85153
content: [
86154
{
87155
type: 'text' as const,
88-
text: `Projects in the Angular workspace:\n${JSON.stringify(projects)}`,
156+
text: `Projects in the Angular workspace:\n${JSON.stringify({ workspaces })}`,
89157
},
90158
],
91-
structuredContent: { projects },
159+
structuredContent: { workspaces },
92160
};
93161
};
94162
}

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)