Skip to content

Commit 8b8c44b

Browse files
committed
refactor(@angular/cli): add type safety fallbacks and deduplicate search roots for MCP projects tool
Added runtime type checks for `projectType` and `prefix` to prevent unhandled schema exceptions. Implemented `deduplicateSearchRoots` to filter out child directories from overlapping search trees, optimizing filesystem scans. Aggregated validation errors and reported them in the tool output for better visibility.
1 parent f1ed025 commit 8b8c44b

File tree

1 file changed

+105
-18
lines changed

1 file changed

+105
-18
lines changed

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

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ const listProjectsOutputSchema = {
112112
)
113113
.default([])
114114
.describe('A list of workspaces for which the framework version could not be determined.'),
115+
validationErrors: z
116+
.array(
117+
z.object({
118+
filePath: z.string().describe('The path to the workspace `angular.json` file.'),
119+
projectName: z.string().describe('The name of the project with invalid schema.'),
120+
message: z.string().describe('The reason why validation failed or fell back.'),
121+
}),
122+
)
123+
.default([])
124+
.describe(
125+
'A list of projects within workspaces that had invalid or malformed schema elements.',
126+
),
115127
};
116128

117129
export const LIST_PROJECTS_TOOL = declareTool({
@@ -331,6 +343,7 @@ async function findAngularCoreVersion(
331343
type WorkspaceData = z.infer<typeof listProjectsOutputSchema.workspaces>[number];
332344
type ParsingError = z.infer<typeof listProjectsOutputSchema.parsingErrors>[number];
333345
type VersioningError = z.infer<typeof listProjectsOutputSchema.versioningErrors>[number];
346+
type ValidationError = z.infer<typeof listProjectsOutputSchema.validationErrors>[number];
334347

335348
/**
336349
* Determines the unit test framework for a project based on its 'test' target configuration.
@@ -455,36 +468,71 @@ async function getProjectStyleLanguage(
455468
async function loadAndParseWorkspace(
456469
configFile: string,
457470
seenPaths: Set<string>,
458-
): Promise<{ workspace: WorkspaceData | null; error: ParsingError | null }> {
471+
): Promise<{
472+
workspace: WorkspaceData | null;
473+
error: ParsingError | null;
474+
validationErrors: ValidationError[];
475+
}> {
459476
try {
460477
const resolvedPath = resolve(configFile);
461478
if (seenPaths.has(resolvedPath)) {
462-
return { workspace: null, error: null }; // Already processed, skip.
479+
return { workspace: null, error: null, validationErrors: [] }; // Already processed, skip.
463480
}
464481
seenPaths.add(resolvedPath);
465482

466483
const ws = await AngularWorkspace.load(configFile);
467-
const projects = [];
484+
const projects: WorkspaceData['projects'] = [];
485+
const validationErrors: ValidationError[] = [];
468486
const workspaceRoot = dirname(configFile);
469487
for (const [name, project] of ws.projects.entries()) {
470488
const sourceRoot = posix.join(project.root, project.sourceRoot ?? 'src');
471489
const fullSourceRoot = join(workspaceRoot, sourceRoot);
472490
const unitTestFramework = getUnitTestFramework(project.targets.get('test'));
473491
const styleLanguage = await getProjectStyleLanguage(project, ws, fullSourceRoot);
474492

493+
const rawType = project.extensions['projectType'];
494+
const type = rawType === 'application' || rawType === 'library' ? rawType : undefined;
495+
if (rawType && !type) {
496+
validationErrors.push({
497+
filePath: configFile,
498+
projectName: name,
499+
message: `Invalid \`projectType\` '${rawType}'. Expected 'application' or 'library'. Falling back to undefined.`,
500+
});
501+
}
502+
503+
const rawPrefix = project.extensions['prefix'];
504+
const selectorPrefix = typeof rawPrefix === 'string' ? rawPrefix : undefined;
505+
if (rawPrefix !== undefined && selectorPrefix === undefined) {
506+
validationErrors.push({
507+
filePath: configFile,
508+
projectName: name,
509+
message: `Invalid \`prefix\`. Expected a string. Falling back to undefined.`,
510+
});
511+
}
512+
513+
const buildBuilder = project.targets.get('build')?.builder;
514+
const builder = typeof buildBuilder === 'string' ? buildBuilder : undefined;
515+
if (buildBuilder !== undefined && builder === undefined) {
516+
validationErrors.push({
517+
filePath: configFile,
518+
projectName: name,
519+
message: `Invalid or missing build builder. Falling back to undefined.`,
520+
});
521+
}
522+
475523
projects.push({
476524
name,
477-
type: project.extensions['projectType'] as 'application' | 'library' | undefined,
478-
builder: project.targets.get('build')?.builder,
525+
type,
526+
builder,
479527
root: project.root,
480528
sourceRoot,
481-
selectorPrefix: project.extensions['prefix'] as string,
529+
selectorPrefix,
482530
unitTestFramework,
483531
styleLanguage,
484532
});
485533
}
486534

487-
return { workspace: { path: configFile, projects }, error: null };
535+
return { workspace: { path: configFile, projects }, error: null, validationErrors };
488536
} catch (error) {
489537
let message;
490538
if (error instanceof Error) {
@@ -493,7 +541,7 @@ async function loadAndParseWorkspace(
493541
message = 'An unknown error occurred while parsing the file.';
494542
}
495543

496-
return { workspace: null, error: { filePath: configFile, message } };
544+
return { workspace: null, error: { filePath: configFile, message }, validationErrors: [] };
497545
}
498546
}
499547

@@ -514,14 +562,15 @@ async function processConfigFile(
514562
workspace?: WorkspaceData;
515563
parsingError?: ParsingError;
516564
versioningError?: VersioningError;
565+
validationErrors?: ValidationError[];
517566
}> {
518-
const { workspace, error } = await loadAndParseWorkspace(configFile, seenPaths);
567+
const { workspace, error, validationErrors } = await loadAndParseWorkspace(configFile, seenPaths);
519568
if (error) {
520569
return { parsingError: error };
521570
}
522571

523572
if (!workspace) {
524-
return {}; // Skipped as it was already seen.
573+
return { validationErrors }; // If already seen, we still group validation errors if any (unlikely to be any if seen).
525574
}
526575

527576
try {
@@ -532,10 +581,11 @@ async function processConfigFile(
532581
searchRoot,
533582
);
534583

535-
return { workspace };
584+
return { workspace, validationErrors };
536585
} catch (e) {
537586
return {
538587
workspace,
588+
validationErrors,
539589
versioningError: {
540590
filePath: workspace.path,
541591
message: e instanceof Error ? e.message : 'An unknown error occurred.',
@@ -544,11 +594,37 @@ async function processConfigFile(
544594
}
545595
}
546596

597+
/**
598+
* Deduplicates overlapping search roots (e.g., if one is a child of another).
599+
* Sorting by length ensures parent directories are processed before children.
600+
* @param roots A list of normalized absolute paths used as search roots.
601+
* @returns A deduplicated list of search roots.
602+
*/
603+
function deduplicateSearchRoots(roots: string[]): string[] {
604+
const sortedRoots = [...roots].sort((a, b) => a.length - b.length);
605+
const deduplicated: string[] = [];
606+
607+
for (const root of sortedRoots) {
608+
const isSubdirectory = deduplicated.some((existing) => {
609+
const rel = relative(existing, root);
610+
611+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
612+
});
613+
614+
if (!isSubdirectory) {
615+
deduplicated.push(root);
616+
}
617+
}
618+
619+
return deduplicated;
620+
}
621+
547622
async function createListProjectsHandler({ server }: McpToolContext) {
548623
return async () => {
549624
const workspaces: WorkspaceData[] = [];
550625
const parsingErrors: ParsingError[] = [];
551626
const versioningErrors: z.infer<typeof listProjectsOutputSchema.versioningErrors> = [];
627+
const validationErrors: ValidationError[] = [];
552628
const seenPaths = new Set<string>();
553629
const versionCache = new Map<string, string | undefined>();
554630

@@ -562,6 +638,8 @@ async function createListProjectsHandler({ server }: McpToolContext) {
562638
searchRoots = [process.cwd()];
563639
}
564640

641+
searchRoots = deduplicateSearchRoots(searchRoots);
642+
565643
// Pre-resolve allowed roots to handle their own symlinks or normalizations.
566644
// We ignore failures here; if a root is broken, we simply won't match against it.
567645
const realAllowedRoots = searchRoots
@@ -576,12 +654,12 @@ async function createListProjectsHandler({ server }: McpToolContext) {
576654

577655
for (const root of searchRoots) {
578656
for await (const configFile of findAngularJsonFiles(root, realAllowedRoots)) {
579-
const { workspace, parsingError, versioningError } = await processConfigFile(
580-
configFile,
581-
root,
582-
seenPaths,
583-
versionCache,
584-
);
657+
const {
658+
workspace,
659+
parsingError,
660+
versioningError,
661+
validationErrors: currentValidationErrors,
662+
} = await processConfigFile(configFile, root, seenPaths, versionCache);
585663

586664
if (workspace) {
587665
workspaces.push(workspace);
@@ -592,6 +670,9 @@ async function createListProjectsHandler({ server }: McpToolContext) {
592670
if (versioningError) {
593671
versioningErrors.push(versioningError);
594672
}
673+
if (currentValidationErrors) {
674+
validationErrors.push(...currentValidationErrors);
675+
}
595676
}
596677
}
597678

@@ -619,10 +700,16 @@ async function createListProjectsHandler({ server }: McpToolContext) {
619700
text += `\n\nWarning: The framework version for the following ${versioningErrors.length} workspace(s) could not be determined:\n`;
620701
text += versioningErrors.map((e) => `- ${e.filePath}: ${e.message}`).join('\n');
621702
}
703+
if (validationErrors.length > 0) {
704+
text += `\n\nWarning: The following ${validationErrors.length} project validation issue(s) were found (properties fell back to defaults):\n`;
705+
text += validationErrors
706+
.map((e) => `- ${e.filePath} [Project: ${e.projectName}]: ${e.message}`)
707+
.join('\n');
708+
}
622709

623710
return {
624711
content: [{ type: 'text' as const, text }],
625-
structuredContent: { workspaces, parsingErrors, versioningErrors },
712+
structuredContent: { workspaces, parsingErrors, versioningErrors, validationErrors },
626713
};
627714
};
628715
}

0 commit comments

Comments
 (0)