diff --git a/packages/angular/cli/src/commands/mcp/tools/examples.ts b/packages/angular/cli/src/commands/mcp/tools/examples.ts index 2a066535d302..445ff04667e8 100644 --- a/packages/angular/cli/src/commands/mcp/tools/examples.ts +++ b/packages/angular/cli/src/commands/mcp/tools/examples.ts @@ -13,42 +13,125 @@ import { z } from 'zod'; import { McpToolContext, declareTool } from './tool-registry'; const findExampleInputSchema = z.object({ - query: z.string().describe( - `Performs a full-text search using FTS5 syntax. The query should target relevant Angular concepts. - -Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation): - - AND (default): Space-separated terms are combined with AND. - - Example: 'standalone component' (finds results with both "standalone" and "component") - - OR: Use the OR operator to find results with either term. - - Example: 'validation OR validator' - - NOT: Use the NOT operator to exclude terms. - - Example: 'forms NOT reactive' - - Grouping: Use parentheses () to group expressions. - - Example: '(validation OR validator) AND forms' - - Phrase Search: Use double quotes "" for exact phrases. - - Example: '"template-driven forms"' - - Prefix Search: Use an asterisk * for prefix matching. - - Example: 'rout*' (matches "route", "router", "routing") - -Examples of queries: - - Find standalone components: 'standalone component' - - Find ngFor with trackBy: 'ngFor trackBy' - - Find signal inputs: 'signal input' - - Find lazy loading a route: 'lazy load route' - - Find forms with validation: 'form AND (validation OR validator)'`, - ), - keywords: z.array(z.string()).optional().describe('Filter examples by specific keywords.'), + 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('Filter examples by required NPM packages (e.g., "@angular/forms").'), + .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('Filter examples by related high-level concepts.'), + .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', @@ -80,15 +163,7 @@ new or evolving features. and 'related_concepts' to create highly specific searches. `, inputSchema: findExampleInputSchema.shape, - outputSchema: { - examples: z.array( - z.object({ - content: z - .string() - .describe('A complete, self-contained Angular code example in Markdown format.'), - }), - ), - }, + outputSchema: findExampleOutputSchema.shape, isReadOnly: true, isLocalOnly: true, shouldRegister: ({ logger }) => { @@ -128,11 +203,16 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) db = new DatabaseSync(exampleDatabasePath, { readOnly: true }); } - const { query, keywords, required_packages, related_concepts } = input; + const { query, keywords, required_packages, related_concepts, includeExperimental } = input; // Build the query dynamically const params: SQLInputValue[] = []; - let sql = 'SELECT content FROM examples_fts'; + 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 @@ -155,6 +235,10 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) addJsonFilter('required_packages', required_packages); addJsonFilter('related_concepts', related_concepts); + if (!includeExperimental) { + whereClauses.push('experimental = 0'); + } + if (whereClauses.length > 0) { sql += ` WHERE ${whereClauses.join(' AND ')}`; } @@ -171,9 +255,26 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) const examples = []; const textContent = []; for (const exampleRecord of queryStatement.all(...params)) { - const exampleContent = exampleRecord['content'] as string; - examples.push({ content: exampleContent }); - textContent.push({ type: 'text' as const, text: exampleContent }); + 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 { @@ -336,6 +437,7 @@ async function setupRuntimeExamples( required_packages TEXT, related_concepts TEXT, related_tools TEXT, + experimental INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL ); `); @@ -369,8 +471,8 @@ async function setupRuntimeExamples( const insertStatement = db.prepare( 'INSERT INTO examples(' + - 'title, summary, keywords, required_packages, related_concepts, related_tools, content' + - ') VALUES(?, ?, ?, ?, ?, ?, ?);', + 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', ); const frontmatterSchema = z.object({ @@ -380,6 +482,7 @@ async function setupRuntimeExamples( 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'); @@ -398,8 +501,15 @@ async function setupRuntimeExamples( continue; } - const { title, summary, keywords, required_packages, related_concepts, related_tools } = - validation.data; + const { + title, + summary, + keywords, + required_packages, + related_concepts, + related_tools, + experimental, + } = validation.data; insertStatement.run( title, @@ -408,6 +518,7 @@ async function setupRuntimeExamples( JSON.stringify(required_packages ?? []), JSON.stringify(related_concepts ?? []), JSON.stringify(related_tools ?? []), + experimental ? 1 : 0, content, ); } diff --git a/tools/example_db_generator.js b/tools/example_db_generator.js index c85b63e497cf..784ff52128dd 100644 --- a/tools/example_db_generator.js +++ b/tools/example_db_generator.js @@ -84,6 +84,7 @@ function generate(inPath, outPath) { required_packages TEXT, related_concepts TEXT, related_tools TEXT, + experimental INTEGER NOT NULL DEFAULT 0, content TEXT NOT NULL ); `); @@ -120,8 +121,8 @@ function generate(inPath, outPath) { const insertStatement = db.prepare( 'INSERT INTO examples(' + - 'title, summary, keywords, required_packages, related_concepts, related_tools, content' + - ') VALUES(?, ?, ?, ?, ?, ?, ?);', + 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', ); const frontmatterSchema = z.object({ @@ -131,6 +132,7 @@ function generate(inPath, outPath) { 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'); @@ -152,8 +154,15 @@ function generate(inPath, outPath) { throw new Error(`Invalid front matter in ${entry.name}`); } - const { title, summary, keywords, required_packages, related_concepts, related_tools } = - validation.data; + const { + title, + summary, + keywords, + required_packages, + related_concepts, + related_tools, + experimental, + } = validation.data; insertStatement.run( title, summary, @@ -161,6 +170,7 @@ function generate(inPath, outPath) { JSON.stringify(required_packages ?? []), JSON.stringify(related_concepts ?? []), JSON.stringify(related_tools ?? []), + experimental ? 1 : 0, content, ); }