diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index dfcd162a44f7..27ef2880336c 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -19,7 +19,7 @@ import { START_DEVSERVER_TOOL } from './tools/devserver/start-devserver'; import { STOP_DEVSERVER_TOOL } from './tools/devserver/stop-devserver'; import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/devserver/wait-for-devserver-build'; import { DOC_SEARCH_TOOL } from './tools/doc-search'; -import { FIND_EXAMPLE_TOOL } from './tools/examples'; +import { FIND_EXAMPLE_TOOL } from './tools/examples/index'; import { MODERNIZE_TOOL } from './tools/modernize'; import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; import { LIST_PROJECTS_TOOL } from './tools/projects'; diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts deleted file mode 100644 index b05b2b4edf97..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ /dev/null @@ -1,755 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import { glob, readFile, stat } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; -import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; -import { z } from 'zod'; -import { type 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( - `The primary, conceptual search query. This should capture the user's main goal or question ` + - `(e.g., 'lazy loading a route' or 'how to use signal inputs'). The query will be processed ` + - 'by a powerful full-text search engine.\n\n' + - 'Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation):\n' + - ' - AND (default): Space-separated terms are combined with AND.\n' + - ' - Example: \'standalone component\' (finds results with both "standalone" and "component")\n' + - ' - OR: Use the OR operator to find results with either term.\n' + - " - Example: 'validation OR validator'\n" + - ' - NOT: Use the NOT operator to exclude terms.\n' + - " - Example: 'forms NOT reactive'\n" + - ' - Grouping: Use parentheses () to group expressions.\n' + - " - Example: '(validation OR validator) AND forms'\n" + - ' - Phrase Search: Use double quotes "" for exact phrases.\n' + - ' - Example: \'"template-driven forms"\'\n' + - ' - Prefix Search: Use an asterisk * for prefix matching.\n' + - ' - Example: \'rout*\' (matches "route", "router", "routing")', - ), - keywords: z - .array(z.string()) - .optional() - .describe( - 'A list of specific, exact keywords to narrow the search. Use this for precise terms like ' + - 'API names, function names, or decorators (e.g., `ngFor`, `trackBy`, `inject`).', - ), - required_packages: z - .array(z.string()) - .optional() - .describe( - "A list of NPM packages that an example must use. Use this when the user's request is " + - 'specific to a feature within a certain package (e.g., if the user asks about `ngModel`, ' + - 'you should filter by `@angular/forms`).', - ), - related_concepts: z - .array(z.string()) - .optional() - .describe( - 'A list of high-level concepts to filter by. Use this to find examples related to broader ' + - 'architectural ideas or patterns (e.g., `signals`, `dependency injection`, `routing`).', - ), - includeExperimental: z - .boolean() - .optional() - .default(false) - .describe( - 'By default, this tool returns only production-safe examples. Set this to `true` **only if** ' + - 'the user explicitly asks for a bleeding-edge feature or if a stable solution to their ' + - 'problem cannot be found. If you set this to `true`, you **MUST** preface your answer by ' + - 'warning the user that the example uses experimental APIs that are not suitable for production.', - ), -}); - -type FindExampleInput = z.infer; - -const findExampleOutputSchema = z.object({ - examples: z.array( - z.object({ - title: z - .string() - .describe( - 'The title of the example. Use this as a heading when presenting the example to the user.', - ), - summary: z - .string() - .describe( - "A one-sentence summary of the example's purpose. Use this to help the user decide " + - 'if the example is relevant to them.', - ), - keywords: z - .array(z.string()) - .optional() - .describe( - 'A list of keywords for the example. You can use these to explain why this example ' + - "was a good match for the user's query.", - ), - required_packages: z - .array(z.string()) - .optional() - .describe( - 'A list of NPM packages required for the example to work. Before presenting the code, ' + - 'you should inform the user if any of these packages need to be installed.', - ), - related_concepts: z - .array(z.string()) - .optional() - .describe( - 'A list of related concepts. You can suggest these to the user as topics for ' + - 'follow-up questions.', - ), - related_tools: z - .array(z.string()) - .optional() - .describe( - 'A list of related MCP tools. You can suggest these as potential next steps for the user.', - ), - content: z - .string() - .describe( - 'A complete, self-contained Angular code example in Markdown format. This should be ' + - 'presented to the user inside a markdown code block.', - ), - snippet: z - .string() - .optional() - .describe( - 'A contextual snippet from the content showing the matched search term. This field is ' + - 'critical for efficiently evaluating a result`s relevance. It enables two primary ' + - 'workflows:\n\n' + - '1. For direct questions: You can internally review snippets to select the single best ' + - 'result before generating a comprehensive answer from its full `content`.\n' + - '2. For ambiguous or exploratory questions: You can present a summary of titles and ' + - 'snippets to the user, allowing them to guide the next step.', - ), - }), - ), -}); - -export const FIND_EXAMPLE_TOOL = declareTool({ - name: 'find_examples', - title: 'Find Angular Code Examples', - description: ` - -Augments your knowledge base with a curated database of official, best-practice code examples, -focusing on **modern, new, and recently updated** Angular features. This tool acts as a RAG -(Retrieval-Augmented Generation) source, providing ground-truth information on the latest Angular -APIs and patterns. You **MUST** use it to understand and apply current standards when working with -new or evolving features. - - -* **Knowledge Augmentation:** Learning about new or updated Angular features (e.g., query: 'signal input' or 'deferrable views'). -* **Modern Implementation:** Finding the correct modern syntax for features - (e.g., query: 'functional route guard' or 'http client with fetch'). -* **Refactoring to Modern Patterns:** Upgrading older code by finding examples of new syntax - (e.g., query: 'built-in control flow' to replace "*ngIf"). -* **Advanced Filtering:** Combining a full-text search with filters to narrow results. - (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. -* The examples in this database are the single source of truth for modern Angular coding patterns. -* The search query uses a powerful full-text search syntax (FTS5). Refer to the 'query' - parameter description for detailed syntax rules and examples. -* You can combine the main 'query' with optional filters like 'keywords', 'required_packages', - and 'related_concepts' to create highly specific searches. -`, - inputSchema: findExampleInputSchema.shape, - outputSchema: findExampleOutputSchema.shape, - isReadOnly: true, - isLocalOnly: true, - shouldRegister: ({ logger }) => { - // sqlite database support requires Node.js 22.16+ - const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); - if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { - logger.warn( - `MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` + - ' Registration of this tool has been skipped.', - ); - - return false; - } - - return true; - }, - factory: createFindExampleHandler, -}); - -/** - * A list of known Angular packages that may contain example databases. - * The tool will attempt to resolve and load example databases from these packages. - */ -const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/forms']; - -/** - * Attempts to find version-specific example databases from the user's installed - * versions of known Angular packages. It looks for a custom `angular` metadata property in each - * package'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 array of objects, each containing a database path and source. - */ -async function getVersionSpecificExampleDatabases( - workspacePath: string, - logger: McpToolContext['logger'], -): Promise<{ dbPath: string; source: string }[]> { - const workspaceRequire = createRequire(workspacePath); - const databases: { dbPath: string; source: string }[] = []; - - for (const packageName of KNOWN_EXAMPLE_PACKAGES) { - // 1. Resolve the path to package.json - let pkgJsonPath: string; - try { - pkgJsonPath = workspaceRequire.resolve(`${packageName}/package.json`); - } catch (e) { - // This is not a warning because the user may not have all known packages installed. - continue; - } - - // 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 = dirname(pkgJsonPath); - const dbPath = resolve(packageDirectory, examplesInfo.path); - - // Ensure the resolved database path is within the package boundary. - const relativePath = relative(packageDirectory, dbPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - logger.warn( - `Detected a potential path traversal attempt in '${pkgJsonPath}'. ` + - `The path '${examplesInfo.path}' escapes the package boundary. ` + - 'This database will be skipped.', - ); - continue; - } - - // 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.', - ); - continue; - } - - const source = `package ${packageName}@${pkgJson.version}`; - databases.push({ dbPath, source }); - } - } catch (e) { - logger.warn( - `Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${ - e instanceof Error ? e.message : e - }.`, - ); - } - } - - return databases; -} - -async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) { - const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR'] - ? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']) - : undefined; - - 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); - } - - const resolvedDbs: { path: string; source: string }[] = []; - - // First, try to get all available version-specific guides. - if (input.workspacePath) { - const versionSpecificDbs = await getVersionSpecificExampleDatabases( - input.workspacePath, - logger, - ); - for (const db of versionSpecificDbs) { - resolvedDbs.push({ path: db.dbPath, source: db.source }); - } - } - - // If no version-specific guides were found for any reason, fall back to the bundled version. - if (resolvedDbs.length === 0 && exampleDatabasePath) { - resolvedDbs.push({ path: exampleDatabasePath, source: 'bundled' }); - } - - if (resolvedDbs.length === 0) { - // This should be prevented by the registration logic in mcp-server.ts - throw new Error('No example databases are available.'); - } - - const { DatabaseSync } = await import('node:sqlite'); - const dbConnections: DatabaseSync[] = []; - - for (const { path, source } of resolvedDbs) { - const db = new DatabaseSync(path, { readOnly: true }); - try { - validateDatabaseSchema(db, source); - dbConnections.push(db); - } catch (e) { - logger.warn((e as Error).message); - // If a database is invalid, we should not query it, but we should not fail the whole tool. - // We will just skip this database and try to use the others. - continue; - } - } - - if (dbConnections.length === 0) { - throw new Error('All available example databases were invalid. Cannot perform query.'); - } - - return queryDatabase(dbConnections, input); - }; -} - -function queryDatabase(dbs: DatabaseSync[], input: FindExampleInput) { - const { query, keywords, required_packages, related_concepts, includeExperimental } = input; - - // Build the query dynamically - const params: SQLInputValue[] = []; - let sql = - `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, " + - // The `bm25` function returns the relevance score of the match. 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 - 'bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0) AS rank ' + - 'FROM examples e JOIN examples_fts ON e.id = examples_fts.rowid'; - 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(`e.${column} LIKE ?`); - params.push(`%"${value}"%`); - } - } - }; - - addJsonFilter('keywords', keywords); - addJsonFilter('required_packages', required_packages); - addJsonFilter('related_concepts', related_concepts); - - if (!includeExperimental) { - whereClauses.push('e.experimental = 0'); - } - - if (whereClauses.length > 0) { - sql += ` WHERE ${whereClauses.join(' AND ')}`; - } - - // Query database and return results - const examples = []; - const textContent = []; - - for (const db of dbs) { - const queryStatement = db.prepare(sql); - for (const exampleRecord of queryStatement.all(...params)) { - const record = exampleRecord as Record; - const example = { - title: record['title'] as string, - summary: record['summary'] as string, - keywords: JSON.parse((record['keywords'] as string) || '[]') as string[], - required_packages: JSON.parse((record['required_packages'] as string) || '[]') as string[], - related_concepts: JSON.parse((record['related_concepts'] as string) || '[]') as string[], - related_tools: JSON.parse((record['related_tools'] as string) || '[]') as string[], - content: record['content'] as string, - snippet: record['snippet'] as string, - rank: record['rank'] as number, - }; - examples.push(example); - } - } - - // Order the combined results by relevance. - // The `bm25` algorithm returns a smaller number for a more relevant match. - examples.sort((a, b) => a.rank - b.rank); - - // The `rank` field is an internal implementation detail for sorting and should not be - // returned to the user. We create a new array of examples without the `rank`. - const finalExamples = examples.map(({ rank, ...rest }) => rest); - - for (const example of finalExamples) { - // 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: finalExamples }, - }; -} - -/** - * Escapes a search query for FTS5 by tokenizing and quoting terms. - * - * This function processes a raw search string and prepares it for an FTS5 full-text search. - * It correctly handles quoted phrases, logical operators (AND, OR, NOT), parentheses, - * and prefix searches (ending with an asterisk), ensuring that individual search - * terms are properly quoted to be treated as literals by the search engine. - * This is primarily intended to avoid unintentional usage of FTS5 query syntax by consumers. - * - * @param query The raw search query string. - * @returns A sanitized query string suitable for FTS5. - */ -export function escapeSearchQuery(query: string): string { - // This regex tokenizes the query string into parts: - // 1. Quoted phrases (e.g., "foo bar") - // 2. Parentheses ( and ) - // 3. FTS5 operators (AND, OR, NOT, NEAR) - // 4. Words, which can include a trailing asterisk for prefix search (e.g., foo*) - const tokenizer = /"([^"]*)"|([()])|\b(AND|OR|NOT|NEAR)\b|([^\s()]+)/g; - let match; - const result: string[] = []; - let lastIndex = 0; - - while ((match = tokenizer.exec(query)) !== null) { - // Add any whitespace or other characters between tokens - if (match.index > lastIndex) { - result.push(query.substring(lastIndex, match.index)); - } - - const [, quoted, parenthesis, operator, term] = match; - - if (quoted !== undefined) { - // It's a quoted phrase, keep it as is. - result.push(`"${quoted}"`); - } else if (parenthesis) { - // It's a parenthesis, keep it as is. - result.push(parenthesis); - } else if (operator) { - // It's an operator, keep it as is. - result.push(operator); - } else if (term) { - // It's a term that needs to be quoted. - if (term.endsWith('*')) { - result.push(`"${term.slice(0, -1)}"*`); - } else { - result.push(`"${term}"`); - } - } - lastIndex = tokenizer.lastIndex; - } - - // Add any remaining part of the string - if (lastIndex < query.length) { - result.push(query.substring(lastIndex)); - } - - return result.join(''); -} - -/** - * Suppresses the experimental warning emitted by Node.js for the `node:sqlite` module. - * - * This is a workaround to prevent the console from being cluttered with warnings - * about the experimental status of the SQLite module, which is used by this tool. - */ -function suppressSqliteWarning() { - const originalProcessEmit = process.emit; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - process.emit = function (event: string, error?: unknown): any { - if ( - event === 'warning' && - error instanceof Error && - error.name === 'ExperimentalWarning' && - error.message.includes('SQLite') - ) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-rest-params - return originalProcessEmit.apply(process, arguments as any); - }; -} - -/** - * A simple YAML front matter parser. - * - * This function extracts the YAML block enclosed by `---` at the beginning of a string - * and parses it into a JavaScript object. It is not a full YAML parser and only - * supports simple key-value pairs and string arrays. - * - * @param content The string content to parse. - * @returns A record containing the parsed front matter data. - */ -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\r?\n(.*?)\r?\n---/s); - if (!match) { - return {}; - } - - const frontmatter = match[1]; - const data: Record = {}; - const lines = frontmatter.split(/\r?\n/); - - let currentKey = ''; - let isArray = false; - const arrayValues: string[] = []; - - for (const line of lines) { - const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); - if (keyValueMatch) { - if (currentKey && isArray) { - data[currentKey] = arrayValues.slice(); - arrayValues.length = 0; - } - - const [, key, value] = keyValueMatch; - currentKey = key.trim(); - isArray = value.trim() === ''; - - if (!isArray) { - const trimmedValue = value.trim(); - if (trimmedValue === 'true') { - data[currentKey] = true; - } else if (trimmedValue === 'false') { - data[currentKey] = false; - } else { - data[currentKey] = trimmedValue; - } - } - } else { - const arrayItemMatch = line.match(/^\s*-\s*(.*)/); - if (arrayItemMatch && currentKey && isArray) { - 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); - } - } - } - - if (currentKey && isArray) { - data[currentKey] = arrayValues; - } - - return data; -} - -async function setupRuntimeExamples(examplesPath: string): Promise { - const { DatabaseSync } = await import('node:sqlite'); - const db = new DatabaseSync(':memory:'); - - // Create a relational table to store the structured example data. - db.exec(` - CREATE TABLE metadata ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ); - `); - - db.exec(` - INSERT INTO metadata (key, value) VALUES - ('schema_version', '1'), - ('created_at', '${new Date().toISOString()}'); - `); - - db.exec(` - CREATE TABLE examples ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - summary TEXT NOT NULL, - keywords TEXT, - required_packages TEXT, - related_concepts TEXT, - related_tools TEXT, - experimental INTEGER NOT NULL DEFAULT 0, - content TEXT NOT NULL - ); - `); - - // Create an FTS5 virtual table to provide full-text search capabilities. - db.exec(` - CREATE VIRTUAL TABLE examples_fts USING fts5( - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - content, - content='examples', - content_rowid='id', - tokenize = 'porter ascii' - ); - `); - - // Create triggers to keep the FTS table synchronized with the examples table. - db.exec(` - CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN - INSERT INTO examples_fts(rowid, title, summary, keywords, required_packages, related_concepts, related_tools, content) - VALUES ( - new.id, new.title, new.summary, new.keywords, new.required_packages, new.related_concepts, - new.related_tools, new.content - ); - END; - `); - - const insertStatement = db.prepare( - 'INSERT INTO examples(' + - 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + - ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', - ); - - const frontmatterSchema = z.object({ - title: z.string(), - summary: z.string(), - keywords: z.array(z.string()).optional(), - required_packages: z.array(z.string()).optional(), - related_concepts: z.array(z.string()).optional(), - related_tools: z.array(z.string()).optional(), - experimental: z.boolean().optional(), - }); - - db.exec('BEGIN TRANSACTION'); - for await (const entry of glob('**/*.md', { cwd: examplesPath, withFileTypes: true })) { - if (!entry.isFile()) { - continue; - } - - const content = await readFile(join(entry.parentPath, entry.name), 'utf-8'); - const frontmatter = parseFrontmatter(content); - - const validation = frontmatterSchema.safeParse(frontmatter); - if (!validation.success) { - // eslint-disable-next-line no-console - console.warn(`Skipping invalid example file ${entry.name}:`, validation.error.issues); - continue; - } - - const { - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - experimental, - } = validation.data; - - insertStatement.run( - title, - summary, - JSON.stringify(keywords ?? []), - JSON.stringify(required_packages ?? []), - JSON.stringify(related_concepts ?? []), - JSON.stringify(related_tools ?? []), - experimental ? 1 : 0, - content, - ); - } - db.exec('END TRANSACTION'); - - return db; -} - -const EXPECTED_SCHEMA_VERSION = 1; - -/** - * Validates the schema version of the example database. - * - * @param db The database connection to validate. - * @param dbSource A string identifying the source of the database (e.g., 'bundled' or a version number). - * @throws An error if the schema version is missing or incompatible. - */ -function validateDatabaseSchema(db: DatabaseSync, dbSource: string): void { - const schemaVersionResult = db - .prepare('SELECT value FROM metadata WHERE key = ?') - .get('schema_version') as { value: string } | undefined; - const actualSchemaVersion = schemaVersionResult ? Number(schemaVersionResult.value) : undefined; - - if (actualSchemaVersion !== EXPECTED_SCHEMA_VERSION) { - db.close(); - - let errorMessage: string; - if (actualSchemaVersion === undefined) { - errorMessage = 'The example database is missing a schema version and cannot be used.'; - } else if (actualSchemaVersion > EXPECTED_SCHEMA_VERSION) { - errorMessage = - `This project's example database (version ${actualSchemaVersion})` + - ` is newer than what this version of the Angular CLI supports (version ${EXPECTED_SCHEMA_VERSION}).` + - ' Please update your `@angular/cli` package to a newer version.'; - } else { - errorMessage = - `This version of the Angular CLI (expects schema version ${EXPECTED_SCHEMA_VERSION})` + - ` requires a newer example database than the one found in this project (version ${actualSchemaVersion}).`; - } - - throw new Error( - `Incompatible example database schema from source '${dbSource}':\n${errorMessage}`, - ); - } -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts b/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts new file mode 100644 index 000000000000..9c9d0cd98d3b --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import type { McpToolContext } from '../tool-registry'; + +/** + * A list of known Angular packages that may contain example databases. + * The tool will attempt to resolve and load example databases from these packages. + */ +const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/forms']; + +/** + * Attempts to find version-specific example databases from the user's installed + * versions of known Angular packages. It looks for a custom `angular` metadata property in each + * package'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 array of objects, each containing a database path and source. + */ +export async function getVersionSpecificExampleDatabases( + workspacePath: string, + logger: McpToolContext['logger'], +): Promise<{ dbPath: string; source: string }[]> { + const workspaceRequire = createRequire(workspacePath); + const databases: { dbPath: string; source: string }[] = []; + + for (const packageName of KNOWN_EXAMPLE_PACKAGES) { + // 1. Resolve the path to package.json + let pkgJsonPath: string; + try { + pkgJsonPath = workspaceRequire.resolve(`${packageName}/package.json`); + } catch (e) { + // This is not a warning because the user may not have all known packages installed. + continue; + } + + // 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 = dirname(pkgJsonPath); + const dbPath = resolve(packageDirectory, examplesInfo.path); + + // Ensure the resolved database path is within the package boundary. + const relativePath = relative(packageDirectory, dbPath); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + logger.warn( + `Detected a potential path traversal attempt in '${pkgJsonPath}'. ` + + `The path '${examplesInfo.path}' escapes the package boundary. ` + + 'This database will be skipped.', + ); + continue; + } + + // 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.', + ); + continue; + } + + const source = `package ${packageName}@${pkgJson.version}`; + databases.push({ dbPath, source }); + } + } catch (e) { + logger.warn( + `Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${ + e instanceof Error ? e.message : e + }.`, + ); + } + } + + return databases; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/database.ts b/packages/angular/cli/src/commands/mcp/tools/examples/database.ts new file mode 100644 index 000000000000..cd4f31a655c0 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/database.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; +import { escapeSearchQuery } from './query-escaper'; +import type { FindExampleInput } from './schemas'; + +const EXPECTED_SCHEMA_VERSION = 1; + +/** + * Validates the schema version of the example database. + * + * @param db The database connection to validate. + * @param dbSource A string identifying the source of the database (e.g., 'bundled' or a version number). + * @throws An error if the schema version is missing or incompatible. + */ +export function validateDatabaseSchema(db: DatabaseSync, dbSource: string): void { + const schemaVersionResult = db + .prepare('SELECT value FROM metadata WHERE key = ?') + .get('schema_version') as { value: string } | undefined; + const actualSchemaVersion = schemaVersionResult ? Number(schemaVersionResult.value) : undefined; + + if (actualSchemaVersion !== EXPECTED_SCHEMA_VERSION) { + db.close(); + + let errorMessage: string; + if (actualSchemaVersion === undefined) { + errorMessage = 'The example database is missing a schema version and cannot be used.'; + } else if (actualSchemaVersion > EXPECTED_SCHEMA_VERSION) { + errorMessage = + `This project's example database (version ${actualSchemaVersion})` + + ` is newer than what this version of the Angular CLI supports (version ${EXPECTED_SCHEMA_VERSION}).` + + ' Please update your `@angular/cli` package to a newer version.'; + } else { + errorMessage = + `This version of the Angular CLI (expects schema version ${EXPECTED_SCHEMA_VERSION})` + + ` requires a newer example database than the one found in this project (version ${actualSchemaVersion}).`; + } + + throw new Error( + `Incompatible example database schema from source '${dbSource}':\n${errorMessage}`, + ); + } +} + +export function queryDatabase(dbs: DatabaseSync[], input: FindExampleInput) { + const { query, keywords, required_packages, related_concepts, includeExperimental } = input; + + // Build the query dynamically + const params: SQLInputValue[] = []; + let sql = + `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, " + + // The `bm25` function returns the relevance score of the match. 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 + 'bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0) AS rank ' + + 'FROM examples e JOIN examples_fts ON e.id = examples_fts.rowid'; + 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(`e.${column} LIKE ?`); + params.push(`%"${value}"%`); + } + } + }; + + addJsonFilter('keywords', keywords); + addJsonFilter('required_packages', required_packages); + addJsonFilter('related_concepts', related_concepts); + + if (!includeExperimental) { + whereClauses.push('e.experimental = 0'); + } + + if (whereClauses.length > 0) { + sql += ` WHERE ${whereClauses.join(' AND ')}`; + } + + // Query database and return results + const examples = []; + const textContent = []; + + for (const db of dbs) { + const queryStatement = db.prepare(sql); + for (const exampleRecord of queryStatement.all(...params)) { + const record = exampleRecord as Record; + const example = { + title: record['title'] as string, + summary: record['summary'] as string, + keywords: JSON.parse((record['keywords'] as string) || '[]') as string[], + required_packages: JSON.parse((record['required_packages'] as string) || '[]') as string[], + related_concepts: JSON.parse((record['related_concepts'] as string) || '[]') as string[], + related_tools: JSON.parse((record['related_tools'] as string) || '[]') as string[], + content: record['content'] as string, + snippet: record['snippet'] as string, + rank: record['rank'] as number, + }; + examples.push(example); + } + } + + // Order the combined results by relevance. + // The `bm25` algorithm returns a smaller number for a more relevant match. + examples.sort((a, b) => a.rank - b.rank); + + // The `rank` field is an internal implementation detail for sorting and should not be + // returned to the user. We create a new array of examples without the `rank`. + const finalExamples = examples.map(({ rank, ...rest }) => rest); + + for (const example of finalExamples) { + // 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: finalExamples }, + }; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/index.ts b/packages/angular/cli/src/commands/mcp/tools/examples/index.ts new file mode 100644 index 000000000000..9ee35b8f1c2c --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/index.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DatabaseSync } from 'node:sqlite'; +import { type McpToolContext, declareTool } from '../tool-registry'; +import { queryDatabase, validateDatabaseSchema } from './database'; +import { getVersionSpecificExampleDatabases } from './database-discovery'; +import { setupRuntimeExamples } from './runtime-database'; +import { type FindExampleInput, findExampleInputSchema, findExampleOutputSchema } from './schemas'; +import { suppressSqliteWarning } from './utils'; + +export const FIND_EXAMPLE_TOOL = declareTool({ + name: 'find_examples', + title: 'Find Angular Code Examples', + description: ` + +Augments your knowledge base with a curated database of official, best-practice code examples, +focusing on **modern, new, and recently updated** Angular features. This tool acts as a RAG +(Retrieval-Augmented Generation) source, providing ground-truth information on the latest Angular +APIs and patterns. You **MUST** use it to understand and apply current standards when working with +new or evolving features. + + +* **Knowledge Augmentation:** Learning about new or updated Angular features (e.g., query: 'signal input' or 'deferrable views'). +* **Modern Implementation:** Finding the correct modern syntax for features + (e.g., query: 'functional route guard' or 'http client with fetch'). +* **Refactoring to Modern Patterns:** Upgrading older code by finding examples of new syntax + (e.g., query: 'built-in control flow' to replace "*ngIf"). +* **Advanced Filtering:** Combining a full-text search with filters to narrow results. + (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. +* The examples in this database are the single source of truth for modern Angular coding patterns. +* The search query uses a powerful full-text search syntax (FTS5). Refer to the 'query' + parameter description for detailed syntax rules and examples. +* You can combine the main 'query' with optional filters like 'keywords', 'required_packages', + and 'related_concepts' to create highly specific searches. +`, + inputSchema: findExampleInputSchema.shape, + outputSchema: findExampleOutputSchema.shape, + isReadOnly: true, + isLocalOnly: true, + shouldRegister: ({ logger }) => { + // sqlite database support requires Node.js 22.16+ + const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); + if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { + logger.warn( + `MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` + + ' Registration of this tool has been skipped.', + ); + + return false; + } + + return true; + }, + factory: createFindExampleHandler, +}); + +async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) { + const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR'] + ? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']) + : undefined; + + 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); + } + + const resolvedDbs: { path: string; source: string }[] = []; + + // First, try to get all available version-specific guides. + if (input.workspacePath) { + const versionSpecificDbs = await getVersionSpecificExampleDatabases( + input.workspacePath, + logger, + ); + for (const db of versionSpecificDbs) { + resolvedDbs.push({ path: db.dbPath, source: db.source }); + } + } + + // If no version-specific guides were found for any reason, fall back to the bundled version. + if (resolvedDbs.length === 0 && exampleDatabasePath) { + resolvedDbs.push({ path: exampleDatabasePath, source: 'bundled' }); + } + + if (resolvedDbs.length === 0) { + // This should be prevented by the registration logic in mcp-server.ts + throw new Error('No example databases are available.'); + } + + const { DatabaseSync } = await import('node:sqlite'); + const dbConnections: DatabaseSync[] = []; + + for (const { path, source } of resolvedDbs) { + const db = new DatabaseSync(path, { readOnly: true }); + try { + validateDatabaseSchema(db, source); + dbConnections.push(db); + } catch (e) { + logger.warn((e as Error).message); + // If a database is invalid, we should not query it, but we should not fail the whole tool. + // We will just skip this database and try to use the others. + continue; + } + } + + if (dbConnections.length === 0) { + throw new Error('All available example databases were invalid. Cannot perform query.'); + } + + return queryDatabase(dbConnections, input); + }; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts new file mode 100644 index 000000000000..bb6acd375d45 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Escapes a search query for FTS5 by tokenizing and quoting terms. + * + * This function processes a raw search string and prepares it for an FTS5 full-text search. + * It correctly handles quoted phrases, logical operators (AND, OR, NOT), parentheses, + * and prefix searches (ending with an asterisk), ensuring that individual search + * terms are properly quoted to be treated as literals by the search engine. + * This is primarily intended to avoid unintentional usage of FTS5 query syntax by consumers. + * + * @param query The raw search query string. + * @returns A sanitized query string suitable for FTS5. + */ +export function escapeSearchQuery(query: string): string { + // This regex tokenizes the query string into parts: + // 1. Quoted phrases (e.g., "foo bar") + // 2. Parentheses ( and ) + // 3. FTS5 operators (AND, OR, NOT, NEAR) + // 4. Words, which can include a trailing asterisk for prefix search (e.g., foo*) + const tokenizer = /"([^"]*)"|([()])|\b(AND|OR|NOT|NEAR)\b|([^\s()]+)/g; + let match; + const result: string[] = []; + let lastIndex = 0; + + while ((match = tokenizer.exec(query)) !== null) { + // Add any whitespace or other characters between tokens + if (match.index > lastIndex) { + result.push(query.substring(lastIndex, match.index)); + } + + const [, quoted, parenthesis, operator, term] = match; + + if (quoted !== undefined) { + // It's a quoted phrase, keep it as is. + result.push(`"${quoted}"`); + } else if (parenthesis) { + // It's a parenthesis, keep it as is. + result.push(parenthesis); + } else if (operator) { + // It's an operator, keep it as is. + result.push(operator); + } else if (term) { + // It's a term that needs to be quoted. + if (term.endsWith('*')) { + result.push(`"${term.slice(0, -1)}"*`); + } else { + result.push(`"${term}"`); + } + } + lastIndex = tokenizer.lastIndex; + } + + // Add any remaining part of the string + if (lastIndex < query.length) { + result.push(query.substring(lastIndex)); + } + + return result.join(''); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples_spec.ts b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts similarity index 97% rename from packages/angular/cli/src/commands/mcp/tools/examples_spec.ts rename to packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts index 9fc75120aee5..6aa801fe0349 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { escapeSearchQuery } from './examples'; +import { escapeSearchQuery } from './query-escaper'; describe('escapeSearchQuery', () => { it('should wrap single terms in double quotes', () => { diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts b/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts new file mode 100644 index 000000000000..2f243eb402b3 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { glob, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; + +/** + * A simple YAML front matter parser. + * + * This function extracts the YAML block enclosed by `---` at the beginning of a string + * and parses it into a JavaScript object. It is not a full YAML parser and only + * supports simple key-value pairs and string arrays. + * + * @param content The string content to parse. + * @returns A record containing the parsed front matter data. + */ +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\r?\n(.*?)\r?\n---/s); + if (!match) { + return {}; + } + + const frontmatter = match[1]; + const data: Record = {}; + const lines = frontmatter.split(/\r?\n/); + + let currentKey = ''; + let isArray = false; + const arrayValues: string[] = []; + + for (const line of lines) { + const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); + if (keyValueMatch) { + if (currentKey && isArray) { + data[currentKey] = arrayValues.slice(); + arrayValues.length = 0; + } + + const [, key, value] = keyValueMatch; + currentKey = key.trim(); + isArray = value.trim() === ''; + + if (!isArray) { + const trimmedValue = value.trim(); + if (trimmedValue === 'true') { + data[currentKey] = true; + } else if (trimmedValue === 'false') { + data[currentKey] = false; + } else { + data[currentKey] = trimmedValue; + } + } + } else { + const arrayItemMatch = line.match(/^\s*-\s*(.*)/); + if (arrayItemMatch && currentKey && isArray) { + 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); + } + } + } + + if (currentKey && isArray) { + data[currentKey] = arrayValues; + } + + return data; +} + +export async function setupRuntimeExamples(examplesPath: string): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(':memory:'); + + // Create a relational table to store the structured example data. + db.exec(` + CREATE TABLE metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + `); + + db.exec(` + INSERT INTO metadata (key, value) VALUES + ('schema_version', '1'), + ('created_at', '${new Date().toISOString()}'); + `); + + db.exec(` + CREATE TABLE examples ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + keywords TEXT, + required_packages TEXT, + related_concepts TEXT, + related_tools TEXT, + experimental INTEGER NOT NULL DEFAULT 0, + content TEXT NOT NULL + ); + `); + + // Create an FTS5 virtual table to provide full-text search capabilities. + db.exec(` + CREATE VIRTUAL TABLE examples_fts USING fts5( + title, + summary, + keywords, + required_packages, + related_concepts, + related_tools, + content, + content='examples', + content_rowid='id', + tokenize = 'porter ascii' + ); + `); + + // Create triggers to keep the FTS table synchronized with the examples table. + db.exec(` + CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN + INSERT INTO examples_fts(rowid, title, summary, keywords, required_packages, related_concepts, related_tools, content) + VALUES ( + new.id, new.title, new.summary, new.keywords, new.required_packages, new.related_concepts, + new.related_tools, new.content + ); + END; + `); + + const insertStatement = db.prepare( + 'INSERT INTO examples(' + + 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', + ); + + const frontmatterSchema = z.object({ + title: z.string(), + summary: z.string(), + keywords: z.array(z.string()).optional(), + required_packages: z.array(z.string()).optional(), + related_concepts: z.array(z.string()).optional(), + related_tools: z.array(z.string()).optional(), + experimental: z.boolean().optional(), + }); + + db.exec('BEGIN TRANSACTION'); + for await (const entry of glob('**/*.md', { cwd: examplesPath, withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + + const content = await readFile(join(entry.parentPath, entry.name), 'utf-8'); + const frontmatter = parseFrontmatter(content); + + const validation = frontmatterSchema.safeParse(frontmatter); + if (!validation.success) { + // eslint-disable-next-line no-console + console.warn(`Skipping invalid example file ${entry.name}:`, validation.error.issues); + continue; + } + + const { + title, + summary, + keywords, + required_packages, + related_concepts, + related_tools, + experimental, + } = validation.data; + + insertStatement.run( + title, + summary, + JSON.stringify(keywords ?? []), + JSON.stringify(required_packages ?? []), + JSON.stringify(related_concepts ?? []), + JSON.stringify(related_tools ?? []), + experimental ? 1 : 0, + content, + ); + } + db.exec('END TRANSACTION'); + + return db; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts b/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts new file mode 100644 index 000000000000..2122f6775bc8 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { z } from 'zod'; + +export 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( + "The primary, conceptual search query. This should capture the user's main goal or question " + + "(e.g., 'lazy loading a route' or 'how to use signal inputs'). The query will be processed " + + 'by a powerful full-text search engine.\n\n' + + 'Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation):\n' + + ' - AND (default): Space-separated terms are combined with AND.\n' + + ' - Example: \'standalone component\' (finds results with both "standalone" and "component")\n' + + ' - OR: Use the OR operator to find results with either term.\n' + + " - Example: 'validation OR validator'\n" + + ' - NOT: Use the NOT operator to exclude terms.\n' + + " - Example: 'forms NOT reactive'\n" + + ' - Grouping: Use parentheses () to group expressions.\n' + + " - Example: '(validation OR validator) AND forms'\n" + + ' - Phrase Search: Use double quotes "" for exact phrases.\n' + + ' - Example: \'"template-driven forms"\'\n' + + ' - Prefix Search: Use an asterisk * for prefix matching.\n' + + ' - Example: \'rout*\' (matches "route", "router", "routing")', + ), + keywords: z + .array(z.string()) + .optional() + .describe( + 'A list of specific, exact keywords to narrow the search. Use this for precise terms like ', + ), + required_packages: z + .array(z.string()) + .optional() + .describe( + "A list of NPM packages that an example must use. Use this when the user's request is " + + 'specific to a feature within a certain package (e.g., if the user asks about `ngModel`, ' + + 'you should filter by `@angular/forms`).', + ), + related_concepts: z + .array(z.string()) + .optional() + .describe( + 'A list of high-level concepts to filter by. Use this to find examples related to broader ' + + 'architectural ideas or patterns (e.g., `signals`, `dependency injection`, `routing`).', + ), + includeExperimental: z + .boolean() + .optional() + .default(false) + .describe( + 'By default, this tool returns only production-safe examples. Set this to `true` **only if** ' + + 'the user explicitly asks for a bleeding-edge feature or if a stable solution to their ' + + 'problem cannot be found. If you set this to `true`, you **MUST** preface your answer by ' + + 'warning the user that the example uses experimental APIs that are not suitable for production.', + ), +}); + +export type FindExampleInput = z.infer; + +export const findExampleOutputSchema = z.object({ + examples: z.array( + z.object({ + title: z + .string() + .describe( + 'The title of the example. Use this as a heading when presenting the example to the user.', + ), + summary: z + .string() + .describe( + "A one-sentence summary of the example's purpose. Use this to help the user decide " + + 'if the example is relevant to them.', + ), + keywords: z + .array(z.string()) + .optional() + .describe( + 'A list of keywords for the example. You can use these to explain why this example ' + + "was a good match for the user's query.", + ), + required_packages: z + .array(z.string()) + .optional() + .describe( + 'A list of NPM packages required for the example to work. Before presenting the code, ' + + 'you should inform the user if any of these packages need to be installed.', + ), + related_concepts: z + .array(z.string()) + .optional() + .describe( + 'A list of related concepts. You can suggest these to the user as topics for ' + + 'follow-up questions.', + ), + related_tools: z + .array(z.string()) + .optional() + .describe( + 'A list of related MCP tools. You can suggest these as potential next steps for the user.', + ), + content: z + .string() + .describe( + 'A complete, self-contained Angular code example in Markdown format. This should be ' + + 'presented to the user inside a markdown code block.', + ), + snippet: z + .string() + .optional() + .describe( + 'A contextual snippet from the content showing the matched search term. This field is ' + + 'critical for efficiently evaluating a result`s relevance. It enables two primary ', + ), + }), + ), +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts b/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts new file mode 100644 index 000000000000..312994d7b3fd --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Suppresses the experimental warning emitted by Node.js for the `node:sqlite` module. + * + * This is a workaround to prevent the console from being cluttered with warnings + * about the experimental status of the SQLite module, which is used by this tool. + */ +export function suppressSqliteWarning(): void { + const originalProcessEmit = process.emit; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.emit = function (event: string, error?: unknown): any { + if ( + event === 'warning' && + error instanceof Error && + error.name === 'ExperimentalWarning' && + error.message.includes('SQLite') + ) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-rest-params + return originalProcessEmit.apply(process, arguments as any); + }; +} diff --git a/tests/legacy-cli/e2e/tests/mcp/find-examples-basic.ts b/tests/legacy-cli/e2e/tests/mcp/find-examples-basic.ts new file mode 100644 index 000000000000..b7f42045076c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/mcp/find-examples-basic.ts @@ -0,0 +1,48 @@ +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); + if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { + console.log('Test bypassed: find_examples tool requires Node.js 22.16 or higher.'); + + return; + } + + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + // Ensure `get_best_practices` returns the markdown content + const { stdout: stdoutInsideWorkspace } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'find_examples', + '--tool-arg', + 'query=if', + ); + + assert.match(stdoutInsideWorkspace, /Using the @if Built-in Control Flow Block/); +}