Skip to content

Commit 4f6b100

Browse files
committed
refactor(@angular/cli): add markdown-dir support to find_examples MCP tool
This commit enhances the `find_examples` tool within the MCP server to load examples directly from directories of structured markdown files, in addition to the existing SQLite format. A new `format` type, `markdown-dir`, is now supported in the `angular.examples` metadata in a package's `package.json`. When this format is detected, the tool will dynamically build an in-memory example database by parsing the markdown files from the specified directory. This change includes: - Safety checks to limit the number and size of markdown files processed. - A backward-compatible format versioning system for the markdown front matter to ensure future compatibility.
1 parent 138649e commit 4f6b100

File tree

1 file changed

+83
-36
lines changed

1 file changed

+83
-36
lines changed

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

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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
229236
async 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

297306
async 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

Comments
 (0)