Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 156 additions & 45 deletions packages/angular/cli/src/commands/mcp/tools/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof findExampleInputSchema>;

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',
Expand Down Expand Up @@ -80,15 +163,7 @@ new or evolving features.
and 'related_concepts' to create highly specific searches.
</Operational Notes>`,
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 }) => {
Expand Down Expand Up @@ -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
Expand All @@ -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 ')}`;
}
Expand All @@ -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<string, string>;
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 {
Expand Down Expand Up @@ -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
);
`);
Expand Down Expand Up @@ -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({
Expand All @@ -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');
Expand All @@ -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,
Expand All @@ -408,6 +518,7 @@ async function setupRuntimeExamples(
JSON.stringify(required_packages ?? []),
JSON.stringify(related_concepts ?? []),
JSON.stringify(related_tools ?? []),
experimental ? 1 : 0,
content,
);
}
Expand Down
18 changes: 14 additions & 4 deletions tools/example_db_generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
`);
Expand Down Expand Up @@ -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({
Expand All @@ -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');
Expand All @@ -152,15 +154,23 @@ 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,
JSON.stringify(keywords ?? []),
JSON.stringify(required_packages ?? []),
JSON.stringify(related_concepts ?? []),
JSON.stringify(related_tools ?? []),
experimental ? 1 : 0,
content,
);
}
Expand Down