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
299 changes: 219 additions & 80 deletions packages/angular/cli/src/commands/mcp/tools/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@
* found in the LICENSE file at https://angular.dev/license
*/

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

const findExampleInputSchema = z.object({
workspacePath: z
.string()
.optional()
.describe(
'The absolute path to the `angular.json` file for the workspace. This is used to find the ' +
'version-specific code examples that correspond to the installed version of the ' +
'Angular framework. You **MUST** get this path from the `list_projects` tool. If omitted, ' +
'the tool will search the generic code examples bundled with the CLI.',
),
query: z
.string()
.describe(
Expand Down Expand Up @@ -153,6 +163,12 @@ new or evolving features.
(e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation'])
</Use Cases>
<Operational Notes>
* **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.
Expand Down Expand Up @@ -183,103 +199,218 @@ new or evolving features.
factory: createFindExampleHandler,
});

async function createFindExampleHandler({ exampleDatabasePath }: McpToolContext) {
let db: DatabaseSync | undefined;
/**
* Attempts to find a version-specific example database from the user's installed
* version of `@angular/core`. It looks for a custom `angular` metadata property in the
* framework's `package.json` to locate the database.
*
* @example A sample `package.json` `angular` field:
* ```json
* {
* "angular": {
* "examples": {
* "format": "sqlite",
* "path": "./resources/code-examples.db"
* }
* }
* }
* ```
*
* @param workspacePath The absolute path to the user's `angular.json` file.
* @param logger The MCP tool context logger for reporting warnings.
* @returns A promise that resolves to an object containing the database path and source,
* or `undefined` if the database could not be resolved.
*/
async function getVersionSpecificExampleDatabase(
workspacePath: string,
logger: McpToolContext['logger'],
): Promise<{ dbPath: string; source: string } | undefined> {
// 1. Resolve the path to package.json
let pkgJsonPath: string;
try {
const workspaceRequire = createRequire(workspacePath);
pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json');
} catch (e) {
logger.warn(
`Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` +
'Is Angular installed in this project? Falling back to the bundled examples.',
);

if (process.env['NG_MCP_EXAMPLES_DIR']) {
db = await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR']);
return undefined;
}

suppressSqliteWarning();
// 2. Read and parse package.json, then find the database.
try {
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
const pkgJson = JSON.parse(pkgJsonContent);
const examplesInfo = pkgJson['angular']?.examples;

if (examplesInfo && examplesInfo.format === 'sqlite' && typeof examplesInfo.path === 'string') {
const packageDirectory = path.dirname(pkgJsonPath);
const dbPath = path.resolve(packageDirectory, examplesInfo.path);

// Ensure the resolved database path is within the package boundary.
const relativePath = path.relative(packageDirectory, dbPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
logger.warn(
`Detected a potential path traversal attempt in '${pkgJsonPath}'. ` +
`The path '${examplesInfo.path}' escapes the package boundary. ` +
'Falling back to the bundled examples.',
);

return undefined;
}

return async (input: FindExampleInput) => {
if (!db) {
if (!exampleDatabasePath) {
// This should be prevented by the registration logic in mcp-server.ts
throw new Error('Example database path is not available.');
// Check the file size to prevent reading a very large file.
const stats = await stat(dbPath);
if (stats.size > 10 * 1024 * 1024) {
// 10MB
logger.warn(
`The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` +
'This is unexpected and the file will not be used. Falling back to the bundled examples.',
);

return undefined;
}
const { DatabaseSync } = await import('node:sqlite');
db = new DatabaseSync(exampleDatabasePath, { readOnly: true });

const source = `framework version ${pkgJson.version}`;

return { dbPath, source };
} else {
logger.warn(
`Did not find valid 'angular.examples' metadata in '${pkgJsonPath}'. ` +
'Falling back to the bundled examples.',
);
}
} catch (e) {
logger.warn(
`Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${
e instanceof Error ? e.message : e
}. Falling back to the bundled examples.`,
);
}

return undefined;
}

async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) {
const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR']
? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR'])
: undefined;

suppressSqliteWarning();

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

// Build the query dynamically
const params: SQLInputValue[] = [];
let sql =
'SELECT title, summary, keywords, required_packages, related_concepts, related_tools, content, ' +
// The `snippet` function generates a contextual snippet of the matched text.
// Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size.
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " +
'FROM examples_fts';
const whereClauses = [];

// FTS query
if (query) {
whereClauses.push('examples_fts MATCH ?');
params.push(escapeSearchQuery(query));
return async (input: FindExampleInput) => {
// If the dev-time override is present, use it and bypass all other logic.
if (runtimeDb) {
return queryDatabase(runtimeDb, input);
}

// JSON array filters
const addJsonFilter = (column: string, values: string[] | undefined) => {
if (values?.length) {
for (const value of values) {
whereClauses.push(`${column} LIKE ?`);
params.push(`%"${value}"%`);
}
}
};
let dbPath: string | undefined;

addJsonFilter('keywords', keywords);
addJsonFilter('required_packages', required_packages);
addJsonFilter('related_concepts', related_concepts);
// First, try to get the version-specific guide.
if (input.workspacePath) {
const versionSpecific = await getVersionSpecificExampleDatabase(input.workspacePath, logger);
if (versionSpecific) {
dbPath = versionSpecific.dbPath;
}
}

if (!includeExperimental) {
whereClauses.push('experimental = 0');
// If the version-specific guide was not found for any reason, fall back to the bundled version.
if (!dbPath) {
dbPath = exampleDatabasePath;
}

if (whereClauses.length > 0) {
sql += ` WHERE ${whereClauses.join(' AND ')}`;
if (!dbPath) {
// This should be prevented by the registration logic in mcp-server.ts
throw new Error('Example database path is not available.');
}

// Order the results by relevance using the BM25 algorithm.
// The weights assigned to each column boost the ranking of documents where the
// search term appears in a more important field.
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);';

const queryStatement = db.prepare(sql);

// Query database and return results
const examples = [];
const textContent = [];
for (const exampleRecord of queryStatement.all(...params)) {
const record = exampleRecord as Record<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}`;
const { DatabaseSync } = await import('node:sqlite');
const db = new DatabaseSync(dbPath, { readOnly: true });

return queryDatabase(db, input);
};
}

function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
const { query, keywords, required_packages, related_concepts, includeExperimental } = input;

// Build the query dynamically
const params: SQLInputValue[] = [];
let sql =
`SELECT e.title, e.summary, e.keywords, e.required_packages, e.related_concepts, e.related_tools, e.content, ` +
// The `snippet` function generates a contextual snippet of the matched text.
// Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size.
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " +
'FROM examples 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}"%`);
}
text += `\n\n---\n\n${example.content}`;
textContent.push({ type: 'text' as const, text });
}
};

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

if (!includeExperimental) {
whereClauses.push('e.experimental = 0');
}

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

return {
content: textContent,
structuredContent: { examples },
// Order the results by relevance using the BM25 algorithm.
// The weights assigned to each column boost the ranking of documents where the
// search term appears in a more important field.
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);';

const queryStatement = db.prepare(sql);

// Query database and return results
const examples = [];
const textContent = [];
for (const exampleRecord of queryStatement.all(...params)) {
const record = exampleRecord as Record<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 {
content: textContent,
structuredContent: { examples },
};
}

Expand Down Expand Up @@ -415,7 +546,15 @@ function parseFrontmatter(content: string): Record<string, unknown> {
} else {
const arrayItemMatch = line.match(/^\s*-\s*(.*)/);
if (arrayItemMatch && currentKey && isArray) {
arrayValues.push(arrayItemMatch[1].trim());
let value = arrayItemMatch[1].trim();
// Unquote if the value is quoted.
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1);
}
arrayValues.push(value);
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion tools/example_db_generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ function parseFrontmatter(content) {
} else {
const arrayItemMatch = line.match(/^\s*-\s*(.*)/);
if (arrayItemMatch && currentKey && isArray) {
arrayValues.push(arrayItemMatch[1].trim());
let value = arrayItemMatch[1].trim();
// Unquote if the value is quoted.
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1);
}
arrayValues.push(value);
}
}
}
Expand Down