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' ;
1020import 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
1339export 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