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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"verdaccio": "6.1.6",
"verdaccio-auth-memory": "^10.0.0",
"yargs-parser": "22.0.0",
"zod": "4.1.5",
"zone.js": "^0.15.0"
},
"dependenciesMeta": {
Expand Down
196 changes: 185 additions & 11 deletions packages/angular/cli/src/commands/mcp/tools/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { glob, readFile } from 'node:fs/promises';
import path from 'node:path';
import type { SQLInputValue } from 'node:sqlite';
import { z } from 'zod';
import { McpToolContext, declareTool } from './tool-registry';

Expand Down Expand Up @@ -36,6 +37,15 @@ Examples of queries:
- 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.'),
required_packages: z
.array(z.string())
.optional()
.describe('Filter examples by required NPM packages (e.g., "@angular/forms").'),
related_concepts: z
.array(z.string())
.optional()
.describe('Filter examples by related high-level concepts.'),
});
type FindExampleInput = z.infer<typeof findExampleInputSchema>;

Expand All @@ -55,7 +65,9 @@ new or evolving features.
* **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').
(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'])
</Use Cases>
<Operational Notes>
* **Tool Selection:** This database primarily contains examples for new and recently updated Angular
Expand All @@ -64,6 +76,8 @@ new or evolving features.
* 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.
</Operational Notes>`,
inputSchema: findExampleInputSchema.shape,
outputSchema: {
Expand Down Expand Up @@ -104,7 +118,7 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext)

suppressSqliteWarning();

return async ({ query }: FindExampleInput) => {
return async (input: FindExampleInput) => {
if (!db) {
if (!exampleDatabasePath) {
// This should be prevented by the registration logic in mcp-server.ts
Expand All @@ -113,16 +127,50 @@ async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext)
const { DatabaseSync } = await import('node:sqlite');
db = new DatabaseSync(exampleDatabasePath, { readOnly: true });
}
if (!queryStatement) {
queryStatement = db.prepare('SELECT * from examples WHERE examples MATCH ? ORDER BY rank;');

const { query, keywords, required_packages, related_concepts } = input;

// Build the query dynamically
const params: SQLInputValue[] = [];
let sql = 'SELECT content 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}"%`);
}
}
};

addJsonFilter('keywords', keywords);
addJsonFilter('required_packages', required_packages);
addJsonFilter('related_concepts', related_concepts);

if (whereClauses.length > 0) {
sql += ` WHERE ${whereClauses.join(' AND ')}`;
}

const sanitizedQuery = escapeSearchQuery(query);
// 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(sanitizedQuery)) {
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 });
Expand Down Expand Up @@ -218,24 +266,150 @@ function suppressSqliteWarning() {
};
}

/**
* 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<string, unknown> {
const match = content.match(/^---\r?\n(.*?)\r?\n---/s);
if (!match) {
return {};
}

const frontmatter = match[1];
const data: Record<string, unknown> = {};
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) {
data[currentKey] = value.trim();
}
} else {
const arrayItemMatch = line.match(/^\s*-\s*(.*)/);
if (arrayItemMatch && currentKey && isArray) {
arrayValues.push(arrayItemMatch[1].trim());
}
}
}

if (currentKey && isArray) {
data[currentKey] = arrayValues;
}

return data;
}

async function setupRuntimeExamples(
examplesPath: string,
): Promise<import('node:sqlite').DatabaseSync> {
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(':memory:');

db.exec(`CREATE VIRTUAL TABLE examples USING fts5(content, tokenize = 'porter ascii');`);
// Create a relational table to store the structured example data.
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,
content TEXT NOT NULL
);
`);

const insertStatement = db.prepare('INSERT INTO examples(content) VALUES(?);');
// 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, 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(),
});

db.exec('BEGIN TRANSACTION');
for await (const entry of glob('*.md', { cwd: examplesPath, withFileTypes: true })) {
for await (const entry of glob('**/*.md', { cwd: examplesPath, withFileTypes: true })) {
if (!entry.isFile()) {
continue;
}

const example = await readFile(path.join(entry.parentPath, entry.name), 'utf-8');
insertStatement.run(example);
const content = await readFile(path.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 } =
validation.data;

insertStatement.run(
title,
summary,
JSON.stringify(keywords ?? []),
JSON.stringify(required_packages ?? []),
JSON.stringify(related_concepts ?? []),
JSON.stringify(related_tools ?? []),
content,
);
}
db.exec('END TRANSACTION');

Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tools/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ js_binary(
name = "ng_example_db",
data = [
"example_db_generator.js",
"//:node_modules/zod",
],
entry_point = "example_db_generator.js",
)
Loading