From d4a78bb4633180e8a26497c1b4687ae4fb4271e9 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:19:26 -0400 Subject: [PATCH 1/3] 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. --- .../cli/src/commands/mcp/tools/examples.ts | 289 +++++++++++++----- 1 file changed, 210 insertions(+), 79 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 21e90163a480..81b7d2fbe869 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -6,13 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ -import { glob, readFile } from 'node:fs/promises'; +import { glob, readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import path from 'node:path'; import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; import { z } from 'zod'; import { McpToolContext, declareTool } from './tool-registry'; const findExampleInputSchema = z.object({ + workspacePath: z + .string() + .optional() + .describe( + 'The absolute path to the `angular.json` file for the workspace. This is used to find the ' + + 'version-specific code examples that correspond to the installed version of the ' + + 'Angular framework. You **MUST** get this path from the `list_projects` tool. If omitted, ' + + 'the tool will search the generic code examples bundled with the CLI.', + ), query: z .string() .describe( @@ -153,6 +163,12 @@ new or evolving features. (e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation']) +* **Project-Specific Use (Recommended):** For tasks inside a user's project, you **MUST** provide the + \`workspacePath\` argument to get examples that match the project's Angular version. Get this + path from \`list_projects\`. +* **General Use:** If no project context is available (e.g., for general questions or learning), + you can call the tool without the \`workspacePath\` argument. It will return the latest + generic examples. * **Tool Selection:** This database primarily contains examples for new and recently updated Angular features. For established, core features, the main documentation (via the \`search_documentation\` tool) may be a better source of information. @@ -183,103 +199,218 @@ new or evolving features. factory: createFindExampleHandler, }); -async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) { - let db: DatabaseSync | undefined; +/** + * Attempts to find a version-specific example database from the user's installed + * version of `@angular/core`. It looks for a custom `angular` metadata property in the + * framework's `package.json` to locate the database. + * + * @example A sample `package.json` `angular` field: + * ```json + * { + * "angular": { + * "examples": { + * "format": "sqlite", + * "path": "./resources/code-examples.db" + * } + * } + * } + * ``` + * + * @param workspacePath The absolute path to the user's `angular.json` file. + * @param logger The MCP tool context logger for reporting warnings. + * @returns A promise that resolves to an object containing the database path and source, + * or `undefined` if the database could not be resolved. + */ +async function getVersionSpecificExampleDatabase( + workspacePath: string, + logger: McpToolContext['logger'], +): Promise<{ dbPath: string; source: string } | undefined> { + // 1. Resolve the path to package.json + let pkgJsonPath: string; + try { + const workspaceRequire = createRequire(workspacePath); + pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json'); + } catch (e) { + logger.warn( + `Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` + + 'Is Angular installed in this project? Falling back to the bundled examples.', + ); - if (process.env['NG_MCP_EXAMPLES_DIR']) { - db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']); + return undefined; } - suppressSqliteWarning(); + // 2. Read and parse package.json, then find the database. + try { + const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8'); + const pkgJson = JSON.parse(pkgJsonContent); + const examplesInfo = pkgJson['angular']?.examples; + + if (examplesInfo && examplesInfo.format === 'sqlite' && typeof examplesInfo.path === 'string') { + const packageDirectory = path.dirname(pkgJsonPath); + const dbPath = path.resolve(packageDirectory, examplesInfo.path); + + // Ensure the resolved database path is within the package boundary. + const relativePath = path.relative(packageDirectory, dbPath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + logger.warn( + `Detected a potential path traversal attempt in '${pkgJsonPath}'. ` + + `The path '${examplesInfo.path}' escapes the package boundary. ` + + 'Falling back to the bundled examples.', + ); + + return undefined; + } - return async (input: FindExampleInput) => { - if (!db) { - if (!exampleDatabasePath) { - // This should be prevented by the registration logic in mcp-server.ts - throw new Error('Example database path is not available.'); + // Check the file size to prevent reading a very large file. + const stats = await stat(dbPath); + if (stats.size > 10 * 1024 * 1024) { + // 10MB + logger.warn( + `The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` + + 'This is unexpected and the file will not be used. Falling back to the bundled examples.', + ); + + return undefined; } - const { DatabaseSync } = await import('node:sqlite'); - db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); + + const source = `framework version ${pkgJson.version}`; + + return { dbPath, source }; + } else { + logger.warn( + `Did not find valid 'angular.examples' metadata in '${pkgJsonPath}'. ` + + 'Falling back to the bundled examples.', + ); } + } catch (e) { + logger.warn( + `Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${ + e instanceof Error ? e.message : e + }. Falling back to the bundled examples.`, + ); + } + + return undefined; +} + +async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) { + const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR'] + ? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']) + : undefined; - const { query, keywords, required_packages, related_concepts, includeExperimental } = input; - - // Build the query dynamically - const params: SQLInputValue[] = []; - let sql = - 'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' + - // The `snippet` function generates a contextual snippet of the matched text. - // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. - "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " + - 'FROM examples_fts'; - const whereClauses = []; - - // FTS query - if (query) { - whereClauses.push('examples_fts MATCH ?'); - params.push(escapeSearchQuery(query)); + suppressSqliteWarning(); + + return async (input: FindExampleInput) => { + // If the dev-time override is present, use it and bypass all other logic. + if (runtimeDb) { + return queryDatabase(runtimeDb, input); } - // JSON array filters - const addJsonFilter = (column: string, values: string[] | undefined) => { - if (values?.length) { - for (const value of values) { - whereClauses.push(`${column} LIKE ?`); - params.push(`%"${value}"%`); - } - } - }; + let dbPath: string | undefined; - addJsonFilter('keywords', keywords); - addJsonFilter('required_packages', required_packages); - addJsonFilter('related_concepts', related_concepts); + // First, try to get the version-specific guide. + if (input.workspacePath) { + const versionSpecific = await getVersionSpecificExampleDatabase(input.workspacePath, logger); + if (versionSpecific) { + dbPath = versionSpecific.dbPath; + } + } - if (!includeExperimental) { - whereClauses.push('experimental = 0'); + // If the version-specific guide was not found for any reason, fall back to the bundled version. + if (!dbPath) { + dbPath = exampleDatabasePath; } - if (whereClauses.length > 0) { - sql += ` WHERE ${whereClauses.join(' AND ')}`; + if (!dbPath) { + // This should be prevented by the registration logic in mcp-server.ts + throw new Error('Example database path is not available.'); } - // Order the results by relevance using the BM25 algorithm. - // The weights assigned to each column boost the ranking of documents where the - // search term appears in a more important field. - // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content - sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);'; - - const queryStatement = db.prepare(sql); - - // Query database and return results - const examples = []; - const textContent = []; - for (const exampleRecord of queryStatement.all(...params)) { - const record = exampleRecord as Record; - const example = { - title: record['title'], - summary: record['summary'], - keywords: JSON.parse(record['keywords'] || '[]') as string[], - required_packages: JSON.parse(record['required_packages'] || '[]') as string[], - related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[], - related_tools: JSON.parse(record['related_tools'] || '[]') as string[], - content: record['content'], - snippet: record['snippet'], - }; - examples.push(example); - - // Also create a more structured text output - let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`; - if (example.snippet) { - text += `\n**Snippet:** ${example.snippet}`; + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(dbPath, { readOnly: true }); + + return queryDatabase(db, input); + }; +} + +function queryDatabase(db: DatabaseSync, input: FindExampleInput) { + const { query, keywords, required_packages, related_concepts, includeExperimental } = input; + + // Build the query dynamically + const params: SQLInputValue[] = []; + let sql = + 'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' + + // The `snippet` function generates a contextual snippet of the matched text. + // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. + "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " + + 'FROM examples_fts'; + const whereClauses = []; + + // FTS query + if (query) { + whereClauses.push('examples_fts MATCH ?'); + params.push(escapeSearchQuery(query)); + } + + // JSON array filters + const addJsonFilter = (column: string, values: string[] | undefined) => { + if (values?.length) { + for (const value of values) { + whereClauses.push(`${column} LIKE ?`); + params.push(`%"${value}"%`); } - text += `\n\n---\n\n${example.content}`; - textContent.push({ type: 'text' as const, text }); } + }; + + addJsonFilter('keywords', keywords); + addJsonFilter('required_packages', required_packages); + addJsonFilter('related_concepts', related_concepts); - return { - content: textContent, - structuredContent: { examples }, + if (!includeExperimental) { + whereClauses.push('experimental = 0'); + } + + if (whereClauses.length > 0) { + sql += ` WHERE ${whereClauses.join(' AND ')}`; + } + + // Order the results by relevance using the BM25 algorithm. + // The weights assigned to each column boost the ranking of documents where the + // search term appears in a more important field. + // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content + sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);'; + + const queryStatement = db.prepare(sql); + + // Query database and return results + const examples = []; + const textContent = []; + for (const exampleRecord of queryStatement.all(...params)) { + const record = exampleRecord as Record; + const example = { + title: record['title'], + summary: record['summary'], + keywords: JSON.parse(record['keywords'] || '[]') as string[], + required_packages: JSON.parse(record['required_packages'] || '[]') as string[], + related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[], + related_tools: JSON.parse(record['related_tools'] || '[]') as string[], + content: record['content'], + snippet: record['snippet'], }; + examples.push(example); + + // Also create a more structured text output + let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`; + if (example.snippet) { + text += `\n**Snippet:** ${example.snippet}`; + } + text += `\n\n---\n\n${example.content}`; + textContent.push({ type: 'text' as const, text }); + } + + return { + content: textContent, + structuredContent: { examples }, }; } From 513d92ef3f340c973bcff64b98d66b89528c157c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:01:10 -0400 Subject: [PATCH 2/3] fix(@angular/cli): correct query in find_examples to prevent runtime error This commit fixes a runtime error in the `find_examples` tool that occurred when filtering examples. The error `no such column: experimental` was caused by an incorrect SQL query that attempted to access a column on the FTS virtual table (`examples_fts`) that only exists on the main content table (`examples`). The fix refactors the database query to use a `JOIN` between the `examples` and `examples_fts` tables. This allows the `WHERE` clause to correctly filter on the `experimental` column from the main table while still leveraging the full-text search capabilities of the virtual table. --- packages/angular/cli/src/commands/mcp/tools/examples.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 81b7d2fbe869..b8dba8fae2b9 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -339,11 +339,11 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) { // Build the query dynamically const params: SQLInputValue[] = []; let sql = - 'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' + + `SELECT e.title, e.summary, e.keywords, e.required_packages, e.related_concepts, e.related_tools, e.content, ` + // The `snippet` function generates a contextual snippet of the matched text. // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " + - 'FROM examples_fts'; + 'FROM examples e JOIN examples_fts ON e.id = examples_fts.rowid'; const whereClauses = []; // FTS query @@ -356,7 +356,7 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) { const addJsonFilter = (column: string, values: string[] | undefined) => { if (values?.length) { for (const value of values) { - whereClauses.push(`${column} LIKE ?`); + whereClauses.push(`e.${column} LIKE ?`); params.push(`%"${value}"%`); } } @@ -367,7 +367,7 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) { addJsonFilter('related_concepts', related_concepts); if (!includeExperimental) { - whereClauses.push('experimental = 0'); + whereClauses.push('e.experimental = 0'); } if (whereClauses.length > 0) { From 0cb974f8448c7b657c7ac6ca820764c016716cba Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:08:00 -0400 Subject: [PATCH 3/3] fix(@angular/cli): correct frontmatter parsing in MCP examples tool This commit fixes a bug in the YAML frontmatter parsing logic that caused string values in arrays to be double-quoted. The issue was present in both the `find_examples` MCP tool's runtime parser and the `example_db_generator.js` script. The `parseFrontmatter` function in both files has been updated to correctly unquote string values, ensuring that the structured data in the examples database is clean and the tool's output is formatted correctly. --- .../angular/cli/src/commands/mcp/tools/examples.ts | 10 +++++++++- tools/example_db_generator.js | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index b8dba8fae2b9..8866761017a6 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -546,7 +546,15 @@ function parseFrontmatter(content: string): Record { } else { const arrayItemMatch = line.match(/^\s*-\s*(.*)/); if (arrayItemMatch && currentKey && isArray) { - arrayValues.push(arrayItemMatch[1].trim()); + let value = arrayItemMatch[1].trim(); + // Unquote if the value is quoted. + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1); + } + arrayValues.push(value); } } } diff --git a/tools/example_db_generator.js b/tools/example_db_generator.js index dc1f7ba8e3be..142bd1e8a7ed 100644 --- a/tools/example_db_generator.js +++ b/tools/example_db_generator.js @@ -60,7 +60,15 @@ function parseFrontmatter(content) { } else { const arrayItemMatch = line.match(/^\s*-\s*(.*)/); if (arrayItemMatch && currentKey && isArray) { - arrayValues.push(arrayItemMatch[1].trim()); + let value = arrayItemMatch[1].trim(); + // Unquote if the value is quoted. + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1); + } + arrayValues.push(value); } } }