@@ -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
296297async 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