@@ -199,6 +199,13 @@ new or evolving features.
199199 factory : createFindExampleHandler ,
200200} ) ;
201201
202+ const SQLITE_FORMAT = 'sqlite' ;
203+ const MARKDOWN_DIR_FORMAT = 'markdown-dir' ;
204+
205+ type ExampleSource =
206+ | { type : typeof SQLITE_FORMAT ; path : string ; source : string }
207+ | { type : typeof MARKDOWN_DIR_FORMAT ; path : string ; source : string } ;
208+
202209/**
203210 * A list of known Angular packages that may contain example databases.
204211 * The tool will attempt to resolve and load example databases from these packages.
@@ -229,9 +236,9 @@ const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/form
229236async function getVersionSpecificExampleDatabases (
230237 workspacePath : string ,
231238 logger : McpToolContext [ 'logger' ] ,
232- ) : Promise < { dbPath : string ; source : string } [ ] > {
239+ ) : Promise < ExampleSource [ ] > {
233240 const workspaceRequire = createRequire ( workspacePath ) ;
234- const databases : { dbPath : string ; source : string } [ ] = [ ] ;
241+ const databases : ExampleSource [ ] = [ ] ;
235242
236243 for ( const packageName of KNOWN_EXAMPLE_PACKAGES ) {
237244 // 1. Resolve the path to package.json
@@ -251,7 +258,7 @@ async function getVersionSpecificExampleDatabases(
251258
252259 if (
253260 examplesInfo &&
254- examplesInfo . format === 'sqlite' &&
261+ ( examplesInfo . format === SQLITE_FORMAT || examplesInfo . format === MARKDOWN_DIR_FORMAT ) &&
255262 typeof examplesInfo . path === 'string'
256263 ) {
257264 const packageDirectory = dirname ( pkgJsonPath ) ;
@@ -268,19 +275,21 @@ async function getVersionSpecificExampleDatabases(
268275 continue ;
269276 }
270277
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 ;
278+ if ( examplesInfo . format === SQLITE_FORMAT ) {
279+ // Check the file size to prevent reading a very large file.
280+ const stats = await stat ( dbPath ) ;
281+ if ( stats . size > 10 * 1024 * 1024 ) {
282+ // 10MB
283+ logger . warn (
284+ `The example database at '${ dbPath } ' is larger than 10MB (${ stats . size } bytes). ` +
285+ 'This is unexpected and the file will not be used.' ,
286+ ) ;
287+ continue ;
288+ }
280289 }
281290
282291 const source = `package ${ packageName } @${ pkgJson . version } ` ;
283- databases . push ( { dbPath, source } ) ;
292+ databases . push ( { type : examplesInfo . format , path : dbPath , source } ) ;
284293 }
285294 } catch ( e ) {
286295 logger . warn (
@@ -296,7 +305,7 @@ async function getVersionSpecificExampleDatabases(
296305
297306async function createFindExampleHandler ( { logger, exampleDatabasePath } : McpToolContext ) {
298307 const runtimeDb = process . env [ 'NG_MCP_EXAMPLES_DIR' ]
299- ? await setupRuntimeExamples ( process . env [ 'NG_MCP_EXAMPLES_DIR' ] )
308+ ? await setupRuntimeExamples ( process . env [ 'NG_MCP_EXAMPLES_DIR' ] , logger )
300309 : undefined ;
301310
302311 suppressSqliteWarning ( ) ;
@@ -307,42 +316,45 @@ async function createFindExampleHandler({ logger, exampleDatabasePath }: McpTool
307316 return queryDatabase ( [ runtimeDb ] , input ) ;
308317 }
309318
310- const resolvedDbs : { path : string ; source : string } [ ] = [ ] ;
319+ const resolvedSources : ExampleSource [ ] = [ ] ;
311320
312321 // First, try to get all available version-specific guides.
313322 if ( input . workspacePath ) {
314323 const versionSpecificDbs = await getVersionSpecificExampleDatabases (
315324 input . workspacePath ,
316325 logger ,
317326 ) ;
318- for ( const db of versionSpecificDbs ) {
319- resolvedDbs . push ( { path : db . dbPath , source : db . source } ) ;
320- }
327+ resolvedSources . push ( ...versionSpecificDbs ) ;
321328 }
322329
323330 // 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' } ) ;
331+ if ( resolvedSources . length === 0 && exampleDatabasePath ) {
332+ resolvedSources . push ( { type : SQLITE_FORMAT , path : exampleDatabasePath , source : 'bundled' } ) ;
326333 }
327334
328- if ( resolvedDbs . length === 0 ) {
335+ if ( resolvedSources . length === 0 ) {
329336 // This should be prevented by the registration logic in mcp-server.ts
330337 throw new Error ( 'No example databases are available.' ) ;
331338 }
332339
333340 const { DatabaseSync } = await import ( 'node:sqlite' ) ;
334341 const dbConnections : DatabaseSync [ ] = [ ] ;
335342
336- for ( const { path, source } of resolvedDbs ) {
337- const db = new DatabaseSync ( path , { readOnly : true } ) ;
338- try {
339- validateDatabaseSchema ( db , source ) ;
343+ for ( const source of resolvedSources ) {
344+ if ( source . type === SQLITE_FORMAT ) {
345+ const db = new DatabaseSync ( source . path , { readOnly : true } ) ;
346+ try {
347+ validateDatabaseSchema ( db , source . source ) ;
348+ dbConnections . push ( db ) ;
349+ } catch ( e ) {
350+ logger . warn ( ( e as Error ) . message ) ;
351+ // If a database is invalid, we should not query it, but we should not fail the whole tool.
352+ // We will just skip this database and try to use the others.
353+ continue ;
354+ }
355+ } else if ( source . type === MARKDOWN_DIR_FORMAT ) {
356+ const db = await setupRuntimeExamples ( source . path , logger ) ;
340357 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 ;
346358 }
347359 }
348360
@@ -599,7 +611,10 @@ function parseFrontmatter(content: string): Record<string, unknown> {
599611 return data ;
600612}
601613
602- async function setupRuntimeExamples ( examplesPath : string ) : Promise < DatabaseSync > {
614+ async function setupRuntimeExamples (
615+ examplesPath : string ,
616+ logger : McpToolContext [ 'logger' ] ,
617+ ) : Promise < DatabaseSync > {
603618 const { DatabaseSync } = await import ( 'node:sqlite' ) ;
604619 const db = new DatabaseSync ( ':memory:' ) ;
605620
@@ -672,21 +687,53 @@ async function setupRuntimeExamples(examplesPath: string): Promise<DatabaseSync>
672687 related_concepts : z . array ( z . string ( ) ) . optional ( ) ,
673688 related_tools : z . array ( z . string ( ) ) . optional ( ) ,
674689 experimental : z . boolean ( ) . optional ( ) ,
690+ format_version : z . preprocess (
691+ ( val ) => ( val === undefined ? 1 : val ) ,
692+ z . literal ( 1 , {
693+ errorMap : ( ) => ( {
694+ message :
695+ 'The example format is incompatible. This version of the CLI requires format_version: 1.' ,
696+ } ) ,
697+ } ) ,
698+ ) ,
675699 } ) ;
676700
701+ const MAX_FILE_COUNT = 1000 ;
702+ const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 ; // 1MB
703+
677704 db . exec ( 'BEGIN TRANSACTION' ) ;
678- for await ( const entry of glob ( '**/*.md' , { cwd : examplesPath , withFileTypes : true } ) ) {
679- if ( ! entry . isFile ( ) ) {
705+ let fileCount = 0 ;
706+ for await ( const filePath of glob ( '**/*.md' , { cwd : examplesPath } ) ) {
707+ if ( fileCount >= MAX_FILE_COUNT ) {
708+ logger . warn (
709+ `Warning: Example directory '${ examplesPath } ' contains more than the maximum allowed ` +
710+ `${ MAX_FILE_COUNT } files. Only the first ${ MAX_FILE_COUNT } files will be processed.` ,
711+ ) ;
712+ break ;
713+ }
714+
715+ const fullPath = join ( examplesPath , filePath ) ;
716+ const stats = await stat ( fullPath ) ;
717+
718+ if ( ! stats . isFile ( ) ) {
719+ continue ;
720+ }
721+ fileCount ++ ;
722+
723+ if ( stats . size > MAX_FILE_SIZE_BYTES ) {
724+ logger . warn (
725+ `Warning: Skipping example file '${ filePath } ' because it exceeds the ` +
726+ `maximum file size of ${ MAX_FILE_SIZE_BYTES } bytes.` ,
727+ ) ;
680728 continue ;
681729 }
682730
683- const content = await readFile ( join ( entry . parentPath , entry . name ) , 'utf-8' ) ;
731+ const content = await readFile ( fullPath , 'utf-8' ) ;
684732 const frontmatter = parseFrontmatter ( content ) ;
685733
686734 const validation = frontmatterSchema . safeParse ( frontmatter ) ;
687735 if ( ! validation . success ) {
688- // eslint-disable-next-line no-console
689- console . warn ( `Skipping invalid example file ${ entry . name } :` , validation . error . issues ) ;
736+ logger . warn ( `Skipping invalid example file ${ filePath } : ` + validation . error . issues ) ;
690737 continue ;
691738 }
692739
0 commit comments