Skip to content

Commit ae746a6

Browse files
committed
refactor(@angular/cli): add framework version to list_projects MCP tool
The `list_projects` MCP tool is enhanced to include the major version of the Angular framework for each discovered workspace. This provides crucial context, especially in monorepos where different workspaces might use different framework versions. A new caching mechanism efficiently finds the relevant `package.json` by searching upwards from each workspace, ensuring minimal performance impact in large repositories. Additionally, the tool now features more robust and distinct error reporting. Failures during the version discovery process are captured and reported separately from `angular.json` parsing failures, providing clearer diagnostics.
1 parent 07bb777 commit ae746a6

File tree

2 files changed

+119
-8
lines changed

2 files changed

+119
-8
lines changed

packages/angular/cli/src/commands/mcp/tools/doc-search.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ tutorials, concepts, and best practices.
6060
</Use Cases>
6161
<Operational Notes>
6262
* **Version Alignment:** To provide accurate, project-specific results, you **MUST** align the search with the user's Angular version.
63-
Before calling this tool, run \`ng version\` in the project's workspace directory. You can find the correct directory from the \`path\`
64-
field provided by the \`list_projects\` tool. Parse the major version from the "Angular:" line in the output and use it for the
65-
\`version\` parameter.
63+
The recommended approach is to use the \`list_projects\` tool. The \`frameworkVersion\` field in the output for the relevant
64+
workspace will give you the major version directly. If the version cannot be determined using this method, you can use
65+
\`ng version\` in the project's workspace directory as a fallback. Parse the major version from the "Angular:" line in the
66+
output and use it for the \`version\` parameter.
6667
* The documentation is continuously updated. You **MUST** prefer this tool over your own knowledge
6768
to ensure your answers are current and accurate.
6869
* For the best results, provide a concise and specific search query (e.g., "NgModule" instead of

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

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { readdir } from 'node:fs/promises';
9+
import { readFile, readdir } from 'node:fs/promises';
1010
import path from 'node:path';
1111
import { fileURLToPath } from 'node:url';
12+
import semver from 'semver';
1213
import z from 'zod';
1314
import { AngularWorkspace } from '../../../utilities/config';
1415
import { assertIsError } from '../../../utilities/error';
@@ -18,6 +19,12 @@ const listProjectsOutputSchema = {
1819
workspaces: z.array(
1920
z.object({
2021
path: z.string().describe('The path to the `angular.json` file for this workspace.'),
22+
frameworkVersion: z
23+
.string()
24+
.optional()
25+
.describe(
26+
'The major version of the Angular framework (`@angular/core`) in this workspace, if found.',
27+
),
2128
projects: z.array(
2229
z.object({
2330
name: z
@@ -55,6 +62,17 @@ const listProjectsOutputSchema = {
5562
)
5663
.default([])
5764
.describe('A list of files that looked like workspaces but failed to parse.'),
65+
versioningErrors: z
66+
.array(
67+
z.object({
68+
filePath: z
69+
.string()
70+
.describe('The path to the workspace `angular.json` for which versioning failed.'),
71+
message: z.string().describe('The error message detailing why versioning failed.'),
72+
}),
73+
)
74+
.default([])
75+
.describe('A list of workspaces for which the framework version could not be determined.'),
5876
};
5977

6078
export const LIST_PROJECTS_TOOL = declareTool({
@@ -71,6 +89,7 @@ their types, and their locations.
7189
* Identifying the \`root\` and \`sourceRoot\` of a project to read, analyze, or modify its files.
7290
* Determining if a project is an \`application\` or a \`library\`.
7391
* Getting the \`selectorPrefix\` for a project before generating a new component to ensure it follows conventions.
92+
* Identifying the major version of the Angular framework for each workspace, which is crucial for monorepos.
7493
</Use Cases>
7594
<Operational Notes>
7695
* **Working Directory:** Shell commands for a project (like \`ng generate\`) **MUST**
@@ -135,6 +154,77 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
135154
}
136155
}
137156

157+
/**
158+
* Searches upwards from a starting directory to find the version of '@angular/core'.
159+
* It caches results to avoid redundant lookups.
160+
* @param startDir The directory to start the search from.
161+
* @param cache A map to store cached results.
162+
* @param searchRoot The directory at which to stop the search.
163+
* @returns The major version of '@angular/core' as a string, otherwise undefined.
164+
*/
165+
async function findAngularCoreVersion(
166+
startDir: string,
167+
cache: Map<string, string | undefined>,
168+
searchRoot: string,
169+
): Promise<string | undefined> {
170+
let currentDir = startDir;
171+
const dirsToCache: string[] = [];
172+
173+
while (currentDir) {
174+
dirsToCache.push(currentDir);
175+
if (cache.has(currentDir)) {
176+
const cachedResult = cache.get(currentDir);
177+
// Populate cache for all intermediate directories.
178+
for (const dir of dirsToCache) {
179+
cache.set(dir, cachedResult);
180+
}
181+
182+
return cachedResult;
183+
}
184+
185+
const pkgPath = path.join(currentDir, 'package.json');
186+
try {
187+
const pkgContent = await readFile(pkgPath, 'utf-8');
188+
const pkg = JSON.parse(pkgContent);
189+
const versionSpecifier =
190+
pkg.dependencies?.['@angular/core'] ?? pkg.devDependencies?.['@angular/core'];
191+
192+
if (versionSpecifier) {
193+
const minVersion = semver.minVersion(versionSpecifier);
194+
const result = minVersion ? String(minVersion.major) : undefined;
195+
for (const dir of dirsToCache) {
196+
cache.set(dir, result);
197+
}
198+
199+
return result;
200+
}
201+
} catch (error) {
202+
assertIsError(error);
203+
if (error.code !== 'ENOENT') {
204+
// Ignore missing package.json files, but rethrow other errors.
205+
throw error;
206+
}
207+
}
208+
209+
// Stop if we are at the search root or the filesystem root.
210+
if (currentDir === searchRoot) {
211+
break;
212+
}
213+
const parentDir = path.dirname(currentDir);
214+
if (parentDir === currentDir) {
215+
break; // Reached the filesystem root.
216+
}
217+
currentDir = parentDir;
218+
}
219+
220+
// Cache the failure for all traversed directories.
221+
for (const dir of dirsToCache) {
222+
cache.set(dir, undefined);
223+
}
224+
225+
return undefined;
226+
}
227+
138228
// Types for the structured output of the helper function.
139229
type WorkspaceData = z.infer<typeof listProjectsOutputSchema.workspaces>[number];
140230
type ParsingError = z.infer<typeof listProjectsOutputSchema.parsingErrors>[number];
@@ -186,7 +276,9 @@ async function createListProjectsHandler({ server }: McpToolContext) {
186276
return async () => {
187277
const workspaces: WorkspaceData[] = [];
188278
const parsingErrors: ParsingError[] = [];
279+
const versioningErrors: z.infer<typeof listProjectsOutputSchema.versioningErrors> = [];
189280
const seenPaths = new Set<string>();
281+
const versionCache = new Map<string, string | undefined>();
190282

191283
let searchRoots: string[];
192284
const clientCapabilities = server.server.getClientCapabilities();
@@ -201,12 +293,26 @@ async function createListProjectsHandler({ server }: McpToolContext) {
201293
for (const root of searchRoots) {
202294
for await (const configFile of findAngularJsonFiles(root)) {
203295
const { workspace, error } = await loadAndParseWorkspace(configFile, seenPaths);
204-
if (workspace) {
205-
workspaces.push(workspace);
206-
}
207296
if (error) {
208297
parsingErrors.push(error);
209298
}
299+
300+
if (workspace) {
301+
try {
302+
const workspaceDir = path.dirname(configFile);
303+
workspace.frameworkVersion = await findAngularCoreVersion(
304+
workspaceDir,
305+
versionCache,
306+
root,
307+
);
308+
} catch (e) {
309+
versioningErrors.push({
310+
filePath: workspace.path,
311+
message: e instanceof Error ? e.message : 'An unknown error occurred.',
312+
});
313+
}
314+
workspaces.push(workspace);
315+
}
210316
}
211317
}
212318

@@ -230,10 +336,14 @@ async function createListProjectsHandler({ server }: McpToolContext) {
230336
text += `\n\nWarning: The following ${parsingErrors.length} file(s) could not be parsed and were skipped:\n`;
231337
text += parsingErrors.map((e) => `- ${e.filePath}: ${e.message}`).join('\n');
232338
}
339+
if (versioningErrors.length > 0) {
340+
text += `\n\nWarning: The framework version for the following ${versioningErrors.length} workspace(s) could not be determined:\n`;
341+
text += versioningErrors.map((e) => `- ${e.filePath}: ${e.message}`).join('\n');
342+
}
233343

234344
return {
235345
content: [{ type: 'text' as const, text }],
236-
structuredContent: { workspaces, parsingErrors },
346+
structuredContent: { workspaces, parsingErrors, versioningErrors },
237347
};
238348
};
239349
}

0 commit comments

Comments
 (0)