Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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)