Skip to content

Commit 406315d

Browse files
committed
feat(@angular/cli): make find_examples tool version-aware
This commit refactors the `find_examples` MCP tool to be version-aware, aligning its behavior with the `get_best_practices` tool. The tool now dynamically resolves the code examples database from the user's installed version of `@angular/core`, ensuring that the provided examples are accurate for their specific project version. Key changes: - The input schema is updated to accept a `workspacePath`. - New logic reads the `angular.examples` metadata from `@angular/core/package.json` to locate the version-specific SQLite database. - If the version-specific database cannot be resolved, the tool gracefully falls back to the generic database bundled with the CLI. - The database querying logic has been extracted into a separate helper function for better code organization.
1 parent 18bf8e7 commit 406315d

File tree

1 file changed

+210
-79
lines changed

1 file changed

+210
-79
lines changed

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

Lines changed: 210 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { glob, readFile } from 'node:fs/promises';
9+
import { glob, readFile, stat } from 'node:fs/promises';
10+
import { createRequire } from 'node:module';
1011
import path from 'node:path';
1112
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
1213
import { z } from 'zod';
1314
import { McpToolContext, declareTool } from './tool-registry';
1415

1516
const findExampleInputSchema = z.object({
17+
workspacePath: z
18+
.string()
19+
.optional()
20+
.describe(
21+
'The absolute path to the `angular.json` file for the workspace. This is used to find the ' +
22+
'version-specific code examples that correspond to the installed version of the ' +
23+
'Angular framework. You **MUST** get this path from the `list_projects` tool. If omitted, ' +
24+
'the tool will search the generic code examples bundled with the CLI.',
25+
),
1626
query: z
1727
.string()
1828
.describe(
@@ -153,6 +163,12 @@ new or evolving features.
153163
(e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation'])
154164
</Use Cases>
155165
<Operational Notes>
166+
* **Project-Specific Use (Recommended):** For tasks inside a user's project, you **MUST** provide the
167+
\`workspacePath\` argument to get examples that match the project's Angular version. Get this
168+
path from \`list_projects\`.
169+
* **General Use:** If no project context is available (e.g., for general questions or learning),
170+
you can call the tool without the \`workspacePath\` argument. It will return the latest
171+
generic examples.
156172
* **Tool Selection:** This database primarily contains examples for new and recently updated Angular
157173
features. For established, core features, the main documentation (via the
158174
\`search_documentation\` tool) may be a better source of information.
@@ -183,103 +199,218 @@ new or evolving features.
183199
factory: createFindExampleHandler,
184200
});
185201

186-
async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) {
187-
let db: DatabaseSync | undefined;
202+
/**
203+
* Attempts to find a version-specific example database from the user's installed
204+
* version of `@angular/core`. It looks for a custom `angular` metadata property in the
205+
* framework's `package.json` to locate the database.
206+
*
207+
* @example A sample `package.json` `angular` field:
208+
* ```json
209+
* {
210+
* "angular": {
211+
* "examples": {
212+
* "format": "sqlite",
213+
* "path": "./resources/code-examples.db"
214+
* }
215+
* }
216+
* }
217+
* ```
218+
*
219+
* @param workspacePath The absolute path to the user's `angular.json` file.
220+
* @param logger The MCP tool context logger for reporting warnings.
221+
* @returns A promise that resolves to an object containing the database path and source,
222+
* or `undefined` if the database could not be resolved.
223+
*/
224+
async function getVersionSpecificExampleDatabase(
225+
workspacePath: string,
226+
logger: McpToolContext['logger'],
227+
): Promise<{ dbPath: string; source: string } | undefined> {
228+
// 1. Resolve the path to package.json
229+
let pkgJsonPath: string;
230+
try {
231+
const workspaceRequire = createRequire(workspacePath);
232+
pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json');
233+
} catch (e) {
234+
logger.warn(
235+
`Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` +
236+
'Is Angular installed in this project? Falling back to the bundled examples.',
237+
);
188238

189-
if (process.env['NG_MCP_EXAMPLES_DIR']) {
190-
db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']);
239+
return undefined;
191240
}
192241

193-
suppressSqliteWarning();
242+
// 2. Read and parse package.json, then find the database.
243+
try {
244+
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
245+
const pkgJson = JSON.parse(pkgJsonContent);
246+
const examplesInfo = pkgJson['angular']?.examples;
247+
248+
if (examplesInfo && examplesInfo.format === 'sqlite' && typeof examplesInfo.path === 'string') {
249+
const packageDirectory = path.dirname(pkgJsonPath);
250+
const dbPath = path.resolve(packageDirectory, examplesInfo.path);
251+
252+
// Ensure the resolved database path is within the package boundary.
253+
const relativePath = path.relative(packageDirectory, dbPath);
254+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
255+
logger.warn(
256+
`Detected a potential path traversal attempt in '${pkgJsonPath}'. ` +
257+
`The path '${examplesInfo.path}' escapes the package boundary. ` +
258+
'Falling back to the bundled examples.',
259+
);
260+
261+
return undefined;
262+
}
194263

195-
return async (input: FindExampleInput) => {
196-
if (!db) {
197-
if (!exampleDatabasePath) {
198-
// This should be prevented by the registration logic in mcp-server.ts
199-
throw new Error('Example database path is not available.');
264+
// Check the file size to prevent reading a very large file.
265+
const stats = await stat(dbPath);
266+
if (stats.size > 10 * 1024 * 1024) {
267+
// 10MB
268+
logger.warn(
269+
`The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` +
270+
'This is unexpected and the file will not be used. Falling back to the bundled examples.',
271+
);
272+
273+
return undefined;
200274
}
201-
const { DatabaseSync } = await import('node:sqlite');
202-
db = new DatabaseSync(exampleDatabasePath, { readOnly: true });
275+
276+
const source = `framework version ${pkgJson.version}`;
277+
278+
return { dbPath, source };
279+
} else {
280+
logger.warn(
281+
`Did not find valid 'angular.examples' metadata in '${pkgJsonPath}'. ` +
282+
'Falling back to the bundled examples.',
283+
);
203284
}
285+
} catch (e) {
286+
logger.warn(
287+
`Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${
288+
e instanceof Error ? e.message : e
289+
}. Falling back to the bundled examples.`,
290+
);
291+
}
292+
293+
return undefined;
294+
}
295+
296+
async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) {
297+
const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR']
298+
? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR'])
299+
: undefined;
204300

205-
const { query, keywords, required_packages, related_concepts, includeExperimental } = input;
206-
207-
// Build the query dynamically
208-
const params: SQLInputValue[] = [];
209-
let sql =
210-
'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' +
211-
// The `snippet` function generates a contextual snippet of the matched text.
212-
// Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size.
213-
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " +
214-
'FROM examples_fts';
215-
const whereClauses = [];
216-
217-
// FTS query
218-
if (query) {
219-
whereClauses.push('examples_fts MATCH ?');
220-
params.push(escapeSearchQuery(query));
301+
suppressSqliteWarning();
302+
303+
return async (input: FindExampleInput) => {
304+
// If the dev-time override is present, use it and bypass all other logic.
305+
if (runtimeDb) {
306+
return queryDatabase(runtimeDb, input);
221307
}
222308

223-
// JSON array filters
224-
const addJsonFilter = (column: string, values: string[] | undefined) => {
225-
if (values?.length) {
226-
for (const value of values) {
227-
whereClauses.push(`${column} LIKE ?`);
228-
params.push(`%"${value}"%`);
229-
}
230-
}
231-
};
309+
let dbPath: string | undefined;
232310

233-
addJsonFilter('keywords', keywords);
234-
addJsonFilter('required_packages', required_packages);
235-
addJsonFilter('related_concepts', related_concepts);
311+
// First, try to get the version-specific guide.
312+
if (input.workspacePath) {
313+
const versionSpecific = await getVersionSpecificExampleDatabase(input.workspacePath, logger);
314+
if (versionSpecific) {
315+
dbPath = versionSpecific.dbPath;
316+
}
317+
}
236318

237-
if (!includeExperimental) {
238-
whereClauses.push('experimental = 0');
319+
// If the version-specific guide was not found for any reason, fall back to the bundled version.
320+
if (!dbPath) {
321+
dbPath = exampleDatabasePath;
239322
}
240323

241-
if (whereClauses.length > 0) {
242-
sql += ` WHERE ${whereClauses.join(' AND ')}`;
324+
if (!dbPath) {
325+
// This should be prevented by the registration logic in mcp-server.ts
326+
throw new Error('Example database path is not available.');
243327
}
244328

245-
// Order the results by relevance using the BM25 algorithm.
246-
// The weights assigned to each column boost the ranking of documents where the
247-
// search term appears in a more important field.
248-
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
249-
sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);';
250-
251-
const queryStatement = db.prepare(sql);
252-
253-
// Query database and return results
254-
const examples = [];
255-
const textContent = [];
256-
for (const exampleRecord of queryStatement.all(...params)) {
257-
const record = exampleRecord as Record<string, string>;
258-
const example = {
259-
title: record['title'],
260-
summary: record['summary'],
261-
keywords: JSON.parse(record['keywords'] || '[]') as string[],
262-
required_packages: JSON.parse(record['required_packages'] || '[]') as string[],
263-
related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[],
264-
related_tools: JSON.parse(record['related_tools'] || '[]') as string[],
265-
content: record['content'],
266-
snippet: record['snippet'],
267-
};
268-
examples.push(example);
269-
270-
// Also create a more structured text output
271-
let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`;
272-
if (example.snippet) {
273-
text += `\n**Snippet:** ${example.snippet}`;
329+
const { DatabaseSync } = await import('node:sqlite');
330+
const db = new DatabaseSync(dbPath, { readOnly: true });
331+
332+
return queryDatabase(db, input);
333+
};
334+
}
335+
336+
function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
337+
const { query, keywords, required_packages, related_concepts, includeExperimental } = input;
338+
339+
// Build the query dynamically
340+
const params: SQLInputValue[] = [];
341+
let sql =
342+
'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' +
343+
// The `snippet` function generates a contextual snippet of the matched text.
344+
// Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size.
345+
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " +
346+
'FROM examples_fts';
347+
const whereClauses = [];
348+
349+
// FTS query
350+
if (query) {
351+
whereClauses.push('examples_fts MATCH ?');
352+
params.push(escapeSearchQuery(query));
353+
}
354+
355+
// JSON array filters
356+
const addJsonFilter = (column: string, values: string[] | undefined) => {
357+
if (values?.length) {
358+
for (const value of values) {
359+
whereClauses.push(`${column} LIKE ?`);
360+
params.push(`%"${value}"%`);
274361
}
275-
text += `\n\n---\n\n${example.content}`;
276-
textContent.push({ type: 'text' as const, text });
277362
}
363+
};
364+
365+
addJsonFilter('keywords', keywords);
366+
addJsonFilter('required_packages', required_packages);
367+
addJsonFilter('related_concepts', related_concepts);
278368

279-
return {
280-
content: textContent,
281-
structuredContent: { examples },
369+
if (!includeExperimental) {
370+
whereClauses.push('experimental = 0');
371+
}
372+
373+
if (whereClauses.length > 0) {
374+
sql += ` WHERE ${whereClauses.join(' AND ')}`;
375+
}
376+
377+
// Order the results by relevance using the BM25 algorithm.
378+
// The weights assigned to each column boost the ranking of documents where the
379+
// search term appears in a more important field.
380+
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
381+
sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);';
382+
383+
const queryStatement = db.prepare(sql);
384+
385+
// Query database and return results
386+
const examples = [];
387+
const textContent = [];
388+
for (const exampleRecord of queryStatement.all(...params)) {
389+
const record = exampleRecord as Record<string, string>;
390+
const example = {
391+
title: record['title'],
392+
summary: record['summary'],
393+
keywords: JSON.parse(record['keywords'] || '[]') as string[],
394+
required_packages: JSON.parse(record['required_packages'] || '[]') as string[],
395+
related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[],
396+
related_tools: JSON.parse(record['related_tools'] || '[]') as string[],
397+
content: record['content'],
398+
snippet: record['snippet'],
282399
};
400+
examples.push(example);
401+
402+
// Also create a more structured text output
403+
let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`;
404+
if (example.snippet) {
405+
text += `\n**Snippet:** ${example.snippet}`;
406+
}
407+
text += `\n\n---\n\n${example.content}`;
408+
textContent.push({ type: 'text' as const, text });
409+
}
410+
411+
return {
412+
content: textContent,
413+
structuredContent: { examples },
283414
};
284415
}
285416

0 commit comments

Comments
 (0)