Skip to content

Commit d74626f

Browse files
committed
fix(@angular/cli): support multi-database search in find_examples MCP tool
This commit enhances the `find_examples` MCP server tool to support searching across multiple example databases. The tool now discovers and loads example databases from a predefined list of known Angular packages (e.g., `@angular/core`, `@angular/forms`). It gracefully handles missing packages and validates the schema of each discovered database. The search query is executed against all valid databases, and the results are aggregated and sorted by relevance, providing the user with a comprehensive and unified set of code examples from across the Angular ecosystem.
1 parent 5e78117 commit d74626f

File tree

1 file changed

+169
-130
lines changed

1 file changed

+169
-130
lines changed

packages/angular/cli/src/commands/mcp/tools/examples.ts

Lines changed: 169 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,15 @@ new or evolving features.
200200
});
201201

202202
/**
203-
* Attempts to find a version-specific example database from the user's installed
204-
* version of `@angular/core`. It looks for a custom `angular` metadata property in the
205-
* framework's `package.json` to locate the database.
203+
* A list of known Angular packages that may contain example databases.
204+
* The tool will attempt to resolve and load example databases from these packages.
205+
*/
206+
const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/forms'];
207+
208+
/**
209+
* Attempts to find version-specific example databases from the user's installed
210+
* versions of known Angular packages. It looks for a custom `angular` metadata property in each
211+
* package's `package.json` to locate the database.
206212
*
207213
* @example A sample `package.json` `angular` field:
208214
* ```json
@@ -218,79 +224,74 @@ new or evolving features.
218224
*
219225
* @param workspacePath The absolute path to the user's `angular.json` file.
220226
* @param logger The MCP tool context logger for reporting warnings.
221-
* @returns A promise that resolves to an object containing the database path and source,
222-
* or `undefined` if the database could not be resolved.
227+
* @returns A promise that resolves to an array of objects, each containing a database path and source.
223228
*/
224-
async function getVersionSpecificExampleDatabase(
229+
async function getVersionSpecificExampleDatabases(
225230
workspacePath: string,
226231
logger: McpToolContext['logger'],
227-
): Promise<{ dbPath: string; source: string } | undefined> {
228-
// 1. Resolve the path to package.json
229-
let pkgJsonPath: string;
230-
try {
231-
const workspaceRequire = createRequire(workspacePath);
232-
pkgJsonPath = workspaceRequire.resolve('@angular/core/package.json');
233-
} catch (e) {
234-
logger.warn(
235-
`Could not resolve '@angular/core/package.json' from '${workspacePath}'. ` +
236-
'Is Angular installed in this project? Falling back to the bundled examples.',
237-
);
238-
239-
return undefined;
240-
}
232+
): Promise<{ dbPath: string; source: string }[]> {
233+
const workspaceRequire = createRequire(workspacePath);
234+
const databases: { dbPath: string; source: string }[] = [];
235+
236+
for (const packageName of KNOWN_EXAMPLE_PACKAGES) {
237+
// 1. Resolve the path to package.json
238+
let pkgJsonPath: string;
239+
try {
240+
pkgJsonPath = workspaceRequire.resolve(`${packageName}/package.json`);
241+
} catch (e) {
242+
// This is not a warning because the user may not have all known packages installed.
243+
continue;
244+
}
241245

242-
// 2. Read and parse package.json, then find the database.
243-
try {
244-
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
245-
const pkgJson = JSON.parse(pkgJsonContent);
246-
const examplesInfo = pkgJson['angular']?.examples;
247-
248-
if (examplesInfo && examplesInfo.format === 'sqlite' && typeof examplesInfo.path === 'string') {
249-
const packageDirectory = dirname(pkgJsonPath);
250-
const dbPath = resolve(packageDirectory, examplesInfo.path);
251-
252-
// Ensure the resolved database path is within the package boundary.
253-
const relativePath = relative(packageDirectory, dbPath);
254-
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
255-
logger.warn(
256-
`Detected a potential path traversal attempt in '${pkgJsonPath}'. ` +
257-
`The path '${examplesInfo.path}' escapes the package boundary. ` +
258-
'Falling back to the bundled examples.',
259-
);
260-
261-
return undefined;
262-
}
246+
// 2. Read and parse package.json, then find the database.
247+
try {
248+
const pkgJsonContent = await readFile(pkgJsonPath, 'utf-8');
249+
const pkgJson = JSON.parse(pkgJsonContent);
250+
const examplesInfo = pkgJson['angular']?.examples;
251+
252+
if (
253+
examplesInfo &&
254+
examplesInfo.format === 'sqlite' &&
255+
typeof examplesInfo.path === 'string'
256+
) {
257+
const packageDirectory = dirname(pkgJsonPath);
258+
const dbPath = resolve(packageDirectory, examplesInfo.path);
259+
260+
// Ensure the resolved database path is within the package boundary.
261+
const relativePath = relative(packageDirectory, dbPath);
262+
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
263+
logger.warn(
264+
`Detected a potential path traversal attempt in '${pkgJsonPath}'. ` +
265+
`The path '${examplesInfo.path}' escapes the package boundary. ` +
266+
'This database will be skipped.',
267+
);
268+
continue;
269+
}
263270

264-
// Check the file size to prevent reading a very large file.
265-
const stats = await stat(dbPath);
266-
if (stats.size > 10 * 1024 * 1024) {
267-
// 10MB
268-
logger.warn(
269-
`The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` +
270-
'This is unexpected and the file will not be used. Falling back to the bundled examples.',
271-
);
271+
// Check the file size to prevent reading a very large file.
272+
const stats = await stat(dbPath);
273+
if (stats.size > 10 * 1024 * 1024) {
274+
// 10MB
275+
logger.warn(
276+
`The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` +
277+
'This is unexpected and the file will not be used.',
278+
);
279+
continue;
280+
}
272281

273-
return undefined;
282+
const source = `package ${packageName}@${pkgJson.version}`;
283+
databases.push({ dbPath, source });
274284
}
275-
276-
const source = `framework version ${pkgJson.version}`;
277-
278-
return { dbPath, source };
279-
} else {
285+
} catch (e) {
280286
logger.warn(
281-
`Did not find valid 'angular.examples' metadata in '${pkgJsonPath}'. ` +
282-
'Falling back to the bundled examples.',
287+
`Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${
288+
e instanceof Error ? e.message : e
289+
}.`,
283290
);
284291
}
285-
} catch (e) {
286-
logger.warn(
287-
`Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${
288-
e instanceof Error ? e.message : e
289-
}. Falling back to the bundled examples.`,
290-
);
291292
}
292293

293-
return undefined;
294+
return databases;
294295
}
295296

296297
async function createFindExampleHandler({ logger, exampleDatabasePath }: McpToolContext) {
@@ -303,69 +304,57 @@ async function createFindExampleHandler({ logger, exampleDatabasePath }: McpTool
303304
return async (input: FindExampleInput) => {
304305
// If the dev-time override is present, use it and bypass all other logic.
305306
if (runtimeDb) {
306-
return queryDatabase(runtimeDb, input);
307+
return queryDatabase([runtimeDb], input);
307308
}
308309

309-
let resolvedDbPath: string | undefined;
310-
let dbSource: string | undefined;
310+
const resolvedDbs: { path: string; source: string }[] = [];
311311

312-
// First, try to get the version-specific guide.
312+
// First, try to get all available version-specific guides.
313313
if (input.workspacePath) {
314-
const versionSpecific = await getVersionSpecificExampleDatabase(input.workspacePath, logger);
315-
if (versionSpecific) {
316-
resolvedDbPath = versionSpecific.dbPath;
317-
dbSource = versionSpecific.source;
314+
const versionSpecificDbs = await getVersionSpecificExampleDatabases(
315+
input.workspacePath,
316+
logger,
317+
);
318+
for (const db of versionSpecificDbs) {
319+
resolvedDbs.push({ path: db.dbPath, source: db.source });
318320
}
319321
}
320322

321-
// If the version-specific guide was not found for any reason, fall back to the bundled version.
322-
if (!resolvedDbPath) {
323-
resolvedDbPath = exampleDatabasePath;
324-
dbSource = 'bundled';
323+
// If no version-specific guides were found for any reason, fall back to the bundled version.
324+
if (resolvedDbs.length === 0 && exampleDatabasePath) {
325+
resolvedDbs.push({ path: exampleDatabasePath, source: 'bundled' });
325326
}
326327

327-
if (!resolvedDbPath) {
328+
if (resolvedDbs.length === 0) {
328329
// This should be prevented by the registration logic in mcp-server.ts
329-
throw new Error('Example database path is not available.');
330+
throw new Error('No example databases are available.');
330331
}
331332

332333
const { DatabaseSync } = await import('node:sqlite');
333-
const db = new DatabaseSync(resolvedDbPath, { readOnly: true });
334-
335-
// Validate the schema version of the database.
336-
const EXPECTED_SCHEMA_VERSION = 1;
337-
const schemaVersionResult = db
338-
.prepare('SELECT value FROM metadata WHERE key = ?')
339-
.get('schema_version') as { value: string } | undefined;
340-
const actualSchemaVersion = schemaVersionResult ? Number(schemaVersionResult.value) : undefined;
341-
342-
if (actualSchemaVersion !== EXPECTED_SCHEMA_VERSION) {
343-
db.close();
344-
345-
let errorMessage: string;
346-
if (actualSchemaVersion === undefined) {
347-
errorMessage = 'The example database is missing a schema version and cannot be used.';
348-
} else if (actualSchemaVersion > EXPECTED_SCHEMA_VERSION) {
349-
errorMessage =
350-
`This project's example database (version ${actualSchemaVersion})` +
351-
` is newer than what this version of the Angular CLI supports (version ${EXPECTED_SCHEMA_VERSION}).` +
352-
' Please update your `@angular/cli` package to a newer version.';
353-
} else {
354-
errorMessage =
355-
`This version of the Angular CLI (expects schema version ${EXPECTED_SCHEMA_VERSION})` +
356-
` requires a newer example database than the one found in this project (version ${actualSchemaVersion}).`;
334+
const dbConnections: DatabaseSync[] = [];
335+
336+
for (const { path, source } of resolvedDbs) {
337+
const db = new DatabaseSync(path, { readOnly: true });
338+
try {
339+
validateDatabaseSchema(db, source);
340+
dbConnections.push(db);
341+
} catch (e) {
342+
logger.warn((e as Error).message);
343+
// If a database is invalid, we should not query it, but we should not fail the whole tool.
344+
// We will just skip this database and try to use the others.
345+
continue;
357346
}
347+
}
358348

359-
throw new Error(
360-
`Incompatible example database schema from source '${dbSource}':\n${errorMessage}`,
361-
);
349+
if (dbConnections.length === 0) {
350+
throw new Error('All available example databases were invalid. Cannot perform query.');
362351
}
363352

364-
return queryDatabase(db, input);
353+
return queryDatabase(dbConnections, input);
365354
};
366355
}
367356

368-
function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
357+
function queryDatabase(dbs: DatabaseSync[], input: FindExampleInput) {
369358
const { query, keywords, required_packages, related_concepts, includeExperimental } = input;
370359

371360
// Build the query dynamically
@@ -374,7 +363,12 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
374363
`SELECT e.title, e.summary, e.keywords, e.required_packages, e.related_concepts, e.related_tools, e.content, ` +
375364
// The `snippet` function generates a contextual snippet of the matched text.
376365
// Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size.
377-
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet " +
366+
"snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet, " +
367+
// The `bm25` function returns the relevance score of the match. The weights
368+
// assigned to each column boost the ranking of documents where the search
369+
// term appears in a more important field.
370+
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
371+
'bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0) AS rank ' +
378372
'FROM examples e JOIN examples_fts ON e.id = examples_fts.rowid';
379373
const whereClauses = [];
380374

@@ -406,31 +400,38 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
406400
sql += ` WHERE ${whereClauses.join(' AND ')}`;
407401
}
408402

409-
// Order the results by relevance using the BM25 algorithm.
410-
// The weights assigned to each column boost the ranking of documents where the
411-
// search term appears in a more important field.
412-
// Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content
413-
sql += ' ORDER BY bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0);';
414-
415-
const queryStatement = db.prepare(sql);
416-
417403
// Query database and return results
418404
const examples = [];
419405
const textContent = [];
420-
for (const exampleRecord of queryStatement.all(...params)) {
421-
const record = exampleRecord as Record<string, string>;
422-
const example = {
423-
title: record['title'],
424-
summary: record['summary'],
425-
keywords: JSON.parse(record['keywords'] || '[]') as string[],
426-
required_packages: JSON.parse(record['required_packages'] || '[]') as string[],
427-
related_concepts: JSON.parse(record['related_concepts'] || '[]') as string[],
428-
related_tools: JSON.parse(record['related_tools'] || '[]') as string[],
429-
content: record['content'],
430-
snippet: record['snippet'],
431-
};
432-
examples.push(example);
433406

407+
for (const db of dbs) {
408+
const queryStatement = db.prepare(sql);
409+
for (const exampleRecord of queryStatement.all(...params)) {
410+
const record = exampleRecord as Record<string, string | number>;
411+
const example = {
412+
title: record['title'] as string,
413+
summary: record['summary'] as string,
414+
keywords: JSON.parse((record['keywords'] as string) || '[]') as string[],
415+
required_packages: JSON.parse((record['required_packages'] as string) || '[]') as string[],
416+
related_concepts: JSON.parse((record['related_concepts'] as string) || '[]') as string[],
417+
related_tools: JSON.parse((record['related_tools'] as string) || '[]') as string[],
418+
content: record['content'] as string,
419+
snippet: record['snippet'] as string,
420+
rank: record['rank'] as number,
421+
};
422+
examples.push(example);
423+
}
424+
}
425+
426+
// Order the combined results by relevance.
427+
// The `bm25` algorithm returns a smaller number for a more relevant match.
428+
examples.sort((a, b) => a.rank - b.rank);
429+
430+
// The `rank` field is an internal implementation detail for sorting and should not be
431+
// returned to the user. We create a new array of examples without the `rank`.
432+
const finalExamples = examples.map(({ rank, ...rest }) => rest);
433+
434+
for (const example of finalExamples) {
434435
// Also create a more structured text output
435436
let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`;
436437
if (example.snippet) {
@@ -442,7 +443,7 @@ function queryDatabase(db: DatabaseSync, input: FindExampleInput) {
442443

443444
return {
444445
content: textContent,
445-
structuredContent: { examples },
446+
structuredContent: { examples: finalExamples },
446447
};
447448
}
448449

@@ -714,3 +715,41 @@ async function setupRuntimeExamples(examplesPath: string): Promise<DatabaseSync>
714715

715716
return db;
716717
}
718+
719+
const EXPECTED_SCHEMA_VERSION = 1;
720+
721+
/**
722+
* Validates the schema version of the example database.
723+
*
724+
* @param db The database connection to validate.
725+
* @param dbSource A string identifying the source of the database (e.g., 'bundled' or a version number).
726+
* @throws An error if the schema version is missing or incompatible.
727+
*/
728+
function validateDatabaseSchema(db: DatabaseSync, dbSource: string): void {
729+
const schemaVersionResult = db
730+
.prepare('SELECT value FROM metadata WHERE key = ?')
731+
.get('schema_version') as { value: string } | undefined;
732+
const actualSchemaVersion = schemaVersionResult ? Number(schemaVersionResult.value) : undefined;
733+
734+
if (actualSchemaVersion !== EXPECTED_SCHEMA_VERSION) {
735+
db.close();
736+
737+
let errorMessage: string;
738+
if (actualSchemaVersion === undefined) {
739+
errorMessage = 'The example database is missing a schema version and cannot be used.';
740+
} else if (actualSchemaVersion > EXPECTED_SCHEMA_VERSION) {
741+
errorMessage =
742+
`This project's example database (version ${actualSchemaVersion})` +
743+
` is newer than what this version of the Angular CLI supports (version ${EXPECTED_SCHEMA_VERSION}).` +
744+
' Please update your `@angular/cli` package to a newer version.';
745+
} else {
746+
errorMessage =
747+
`This version of the Angular CLI (expects schema version ${EXPECTED_SCHEMA_VERSION})` +
748+
` requires a newer example database than the one found in this project (version ${actualSchemaVersion}).`;
749+
}
750+
751+
throw new Error(
752+
`Incompatible example database schema from source '${dbSource}':\n${errorMessage}`,
753+
);
754+
}
755+
}

0 commit comments

Comments
 (0)