Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit f099c91

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 6aa20a5 commit f099c91

File tree

3 files changed

+162
-53
lines changed

3 files changed

+162
-53
lines changed

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

Lines changed: 156 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,173 @@
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';
11+
import { fileURLToPath } from 'node:url';
1012
import z from 'zod';
13+
import { AngularWorkspace } from '../../../utilities/config';
14+
import { assertIsError } from '../../../utilities/error';
1115
import { McpToolContext, declareTool } from './tool-registry';
1216

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

‎packages/angular/cli/src/commands/mcp/tools/tool-registry.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { AngularWorkspace } from '../../../utilities/config';
1313
type ToolConfig = Parameters<McpServer['registerTool']>[1];
1414

1515
export interface McpToolContext {
16+
server: McpServer;
1617
workspace?: AngularWorkspace;
1718
logger: { warn(text: string): void };
1819
exampleDatabasePath?: string;
@@ -46,17 +47,18 @@ export function declareTool<TInput extends ZodRawShape, TOutput extends ZodRawSh
4647

4748
export async function registerTools(
4849
server: McpServer,
49-
context: McpToolContext,
50+
context: Omit<McpToolContext,'server'>,
5051
declarations: AnyMcpToolDeclaration[],
5152
): Promise<void> {
5253
for (const declaration of declarations) {
53-
if (declaration.shouldRegister && !(await declaration.shouldRegister(context))) {
54+
const toolContext = { ...context, server };
55+
if (declaration.shouldRegister && !(await declaration.shouldRegister(toolContext))) {
5456
continue;
5557
}
5658

5759
const { name, factory, shouldRegister, isReadOnly, isLocalOnly, ...config } = declaration;
5860

59-
const handler = await factory(context);
61+
const handler = await factory(toolContext);
6062

6163
// Add declarative characteristics to annotations
6264
config.annotations ??= {};

‎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)

AltStyle によって変換されたページ (->オリジナル) /