Skip to content

Commit 68e7113

Browse files
committed
feat(@angular/cli): make get_best_practices tool version-aware
Introduces a version-aware mechanism for the `get_best_practices` MCP tool to solve the version-mismatch problem where the AI could provide guidance for a different version of Angular than what the user's project has installed. The tool now accepts a `workspacePath` to locate the project's installed `@angular/core` package. It reads a new `angular` metadata property from the framework's `package.json` to find the path to a version-specific `best-practices.md` file co-located within the framework package itself. This change ensures the AI assistant's guidance is perfectly aligned with the project's actual framework version, dramatically increasing its accuracy and reliability. If a version-specific guide cannot be resolved, the tool gracefully falls back to the generic guide bundled with the CLI to ensure backward compatibility. The core MCP server instructions are also updated to guide the AI on the new workflow.
1 parent 434daef commit 68e7113

File tree

2 files changed

+192
-25
lines changed

2 files changed

+192
-25
lines changed

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ equivalent actions.
6868
6969
<Core Workflows & Tool Guide>
7070
* **1. Discover Project Structure (Mandatory First Step):** Always begin by calling
71-
\`list_projects\` to understand the workspace. The outputs from this tool are often
72-
required inputs for other tools.
71+
\`list_projects\` to understand the workspace. The \`path\` property for a workspace
72+
is a required input for other tools.
7373
74-
* **2. Write & Modify Code:** Before writing or changing code, you MUST consult the
75-
\`get_best_practices\` tool to learn the current, non-negotiable coding standards.
74+
* **2. Get Coding Standards:** Before writing or changing code within a project, you **MUST** call
75+
the \`get_best_practices\` tool with the \`workspacePath\` from the previous step to get
76+
version-specific standards. For general knowledge, you can call the tool without this path.
7677
7778
* **3. Answer User Questions:**
7879
- For conceptual questions ("what is..."), use \`search_documentation\`.

packages/angular/cli/src/commands/mcp/tools/best-practices.ts

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

9-
import { readFile } from 'node:fs/promises';
9+
/**
10+
* @fileoverview
11+
* This file defines the `get_best_practices` MCP tool. The tool is designed to be version-aware,
12+
* dynamically resolving the best practices guide from the user's installed version of
13+
* `@angular/core`. It achieves this by reading a custom `angular` metadata block in the
14+
* framework's `package.json`. If this resolution fails, it gracefully falls back to a generic
15+
* guide bundled with the Angular CLI.
16+
*/
17+
18+
import { readFile, stat } from 'node:fs/promises';
19+
import { createRequire } from 'node:module';
1020
import path from 'node:path';
11-
import { declareTool } from './tool-registry';
21+
import { z } from 'zod';
22+
import { VERSION } from '../../../utilities/version';
23+
import { McpToolContext, declareTool } from './tool-registry';
24+
25+
const bestPracticesInputSchema = z.object({
26+
workspacePath: z
27+
.string()
28+
.optional()
29+
.describe(
30+
'The absolute path to the `angular.json` file for the workspace. This is used to find the ' +
31+
'version-specific best practices guide that corresponds to the installed version of the ' +
32+
'Angular framework. You **MUST** get this path from the `list_projects` tool. If omitted, ' +
33+
'the tool will return the generic best practices guide bundled with the CLI.',
34+
),
35+
});
36+
37+
type BestPracticesInput = z.infer<typeof bestPracticesInputSchema>;
1238

1339
export const BEST_PRACTICES_TOOL = declareTool({
1440
name: 'get_best_practices',
@@ -24,33 +50,173 @@ that **MUST** be followed for any task involving the creation, analysis, or modi
2450
* To verify that existing code aligns with current Angular conventions before making changes.
2551
</Use Cases>
2652
<Operational Notes>
53+
* **Project-Specific Use (Recommended):** For tasks inside a user's project, you **MUST** provide the
54+
\`workspacePath\` argument to get the guide that matches the project's Angular version. Get this
55+
path from \`list_projects\`.
56+
* **General Use:** If no project context is available (e.g., for general questions or learning),
57+
you can call the tool without the \`workspacePath\` argument. It will return the latest
58+
generic best practices guide.
2759
* The content of this guide is non-negotiable and reflects the official, up-to-date standards for Angular development.
2860
* You **MUST** internalize and apply the principles from this guide in all subsequent Angular-related tasks.
2961
* Failure to adhere to these best practices will result in suboptimal and outdated code.
3062
</Operational Notes>`,
63+
inputSchema: bestPracticesInputSchema.shape,
3164
isReadOnly: true,
3265
isLocalOnly: true,
33-
factory: () => {
34-
let bestPracticesText: string;
66+
factory: createBestPracticesHandler,
67+
});
68+
69+
/**
70+
* Retrieves the content of the generic best practices guide that is bundled with the CLI.
71+
* This serves as a fallback when a version-specific guide cannot be found.
72+
* @returns A promise that resolves to the string content of the bundled markdown file.
73+
*/
74+
async function getBundledBestPractices(): Promise<string> {
75+
return readFile(path.join(__dirname, '..', 'resources', 'best-practices.md'), 'utf-8');
76+
}
77+
78+
/**
79+
* Attempts to find and read a version-specific best practices guide from the user's installed
80+
* version of `@angular/core`. It looks for a custom `angular` metadata property in the
81+
* framework's `package.json` to locate the guide.
82+
*
83+
* @example A sample `package.json` `angular` field:
84+
* ```json
85+
* {
86+
* "angular": {
87+
* "bestPractices": {
88+
* "format": "markdown",
89+
* "path": "./resources/best-practices.md"
90+
* }
91+
* }
92+
* }
93+
* ```
94+
*
95+
* @param workspacePath The absolute path to the user's `angular.json` file.
96+
* @param logger The MCP tool context logger for reporting warnings.
97+
* @returns A promise that resolves to an object containing the guide's content and source,
98+
* or `undefined` if the guide could not be resolved.
99+
*/
100+
async function getVersionSpecificBestPractices(
101+
workspacePath: string,
102+
logger: McpToolContext['logger'],
103+
): Promise<{ content: string; source: string } | undefined> {
104+
// 1. Resolve the path to package.json
105+
let pkgJsonPath: string;
106+
try {
107+
const workspaceRequire = createRequire(workspacePath);
108+
pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json');
109+
} catch (e) {
110+
logger.warn(
111+
`Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` +
112+
'Is Angular installed in this project? Falling back to the bundled guide.',
113+
);
114+
115+
return undefined;
116+
}
35117

36-
return async () => {
37-
bestPracticesText ??= await readFile(
38-
path.join(__dirname, '..', 'resources', 'best-practices.md'),
39-
'utf-8',
118+
// 2. Read and parse package.json, then find and read the guide.
119+
try {
120+
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
121+
const pkgJson = JSON.parse(pkgJsonContent);
122+
const bestPracticesInfo = pkgJson['angular']?.bestPractices;
123+
124+
if (
125+
bestPracticesInfo &&
126+
bestPracticesInfo.format === 'markdown' &&
127+
typeof bestPracticesInfo.path === 'string'
128+
) {
129+
const packageDirectory = path.dirname(pkgJsonPath);
130+
const guidePath = path.resolve(packageDirectory, bestPracticesInfo.path);
131+
132+
// Ensure the resolved guide path is within the package boundary.
133+
// Uses path.relative to create a cross-platform, case-insensitive check.
134+
// If the relative path starts with '..' or is absolute, it is a traversal attempt.
135+
const relativePath = path.relative(packageDirectory, guidePath);
136+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
137+
logger.warn(
138+
`Detected a potential path traversal attempt in '${pkgJsonPath}'. ` +
139+
`The path '${bestPracticesInfo.path}' escapes the package boundary. ` +
140+
'Falling back to the bundled guide.',
141+
);
142+
143+
return undefined;
144+
}
145+
146+
// Check the file size to prevent reading a very large file.
147+
const stats = await stat(guidePath);
148+
if (stats.size > 1024 * 1024) {
149+
// 1MB
150+
logger.warn(
151+
`The best practices guide at '${guidePath}' is larger than 1MB (${stats.size} bytes). ` +
152+
'This is unexpected and the file will not be read. Falling back to the bundled guide.',
153+
);
154+
155+
return undefined;
156+
}
157+
158+
const content = await readFile(guidePath, 'utf-8');
159+
const source = `framework version ${pkgJson.version}`;
160+
161+
return { content, source };
162+
} else {
163+
logger.warn(
164+
`Did not find valid 'angular.bestPractices' metadata in '${pkgJsonPath}'. ` +
165+
'Falling back to the bundled guide.',
40166
);
167+
}
168+
} catch (e) {
169+
logger.warn(
170+
`Failed to read or parse version-specific best practices referenced in '${pkgJsonPath}': ${
171+
e instanceof Error ? e.message : e
172+
}. Falling back to the bundled guide.`,
173+
);
174+
}
175+
176+
return undefined;
177+
}
41178

42-
return {
43-
content: [
44-
{
45-
type: 'text',
46-
text: bestPracticesText,
47-
annotations: {
48-
audience: ['assistant'],
49-
priority: 0.9,
50-
},
179+
/**
180+
* Creates the handler function for the `get_best_practices` tool.
181+
* The handler orchestrates the process of first attempting to get a version-specific guide
182+
* and then falling back to the bundled guide if necessary.
183+
* @param context The MCP tool context, containing the logger.
184+
* @returns An async function that serves as the tool's executor.
185+
*/
186+
function createBestPracticesHandler({ logger }: McpToolContext) {
187+
let bundledBestPractices: Promise<string>;
188+
189+
return async (input: BestPracticesInput) => {
190+
let content: string | undefined;
191+
let source: string | undefined;
192+
193+
// First, try to get the version-specific guide.
194+
if (input.workspacePath) {
195+
const versionSpecific = await getVersionSpecificBestPractices(input.workspacePath, logger);
196+
if (versionSpecific) {
197+
content = versionSpecific.content;
198+
source = versionSpecific.source;
199+
}
200+
}
201+
202+
// If the version-specific guide was not found for any reason, fall back to the bundled version.
203+
if (content === undefined) {
204+
content = await (bundledBestPractices ??= getBundledBestPractices());
205+
source = `bundled (CLI v${VERSION.full})`;
206+
}
207+
208+
return {
209+
content: [
210+
{
211+
type: 'text' as const,
212+
text: content,
213+
annotations: {
214+
audience: ['assistant'],
215+
priority: 0.9,
216+
source,
51217
},
52-
],
53-
};
218+
},
219+
],
54220
};
55-
},
56-
});
221+
};
222+
}

0 commit comments

Comments
 (0)