From 78937bc2acea27a81c09a603323f3daaedd9caaa Mon Sep 17 00:00:00 2001 From: Ruslan Lekhman Date: Sun, 9 Nov 2025 03:22:49 -0700 Subject: [PATCH] feat(@angular/cli): add schematics mcp tools --- .../cli/src/commands/mcp/mcp-server.ts | 8 +- .../commands/mcp/tools/schematics/index.ts | 13 + .../mcp/tools/schematics/list-schematics.ts | 108 ++++ .../mcp/tools/schematics/run-schematic.ts | 525 ++++++++++++++++++ .../mcp/tools/schematics/schematics_spec.ts | 418 ++++++++++++++ .../commands/mcp/tools/schematics/types.ts | 62 +++ .../commands/mcp/tools/schematics/utils.ts | 446 +++++++++++++++ .../src/commands/mcp/tools/tool-registry.ts | 2 + 8 files changed, 1581 insertions(+), 1 deletion(-) create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/index.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/list-schematics.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/run-schematic.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/schematics_spec.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/types.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/schematics/utils.ts diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index dfcd162a44f7..aff6ddbadd8d 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -23,6 +23,7 @@ import { FIND_EXAMPLE_TOOL } from './tools/examples'; import { MODERNIZE_TOOL } from './tools/modernize'; import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; import { LIST_PROJECTS_TOOL } from './tools/projects'; +import { SCHEMATICS_TOOLS } from './tools/schematics'; import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry'; /** @@ -47,7 +48,12 @@ const STABLE_TOOLS = [ * The set of tools that are available but not enabled by default. * These tools are considered experimental and may have limitations. */ -export const EXPERIMENTAL_TOOLS = [BUILD_TOOL, MODERNIZE_TOOL, ...SERVE_TOOLS] as const; +export const EXPERIMENTAL_TOOLS = [ + BUILD_TOOL, + MODERNIZE_TOOL, + ...SCHEMATICS_TOOLS, + ...SERVE_TOOLS, +] as const; export async function createMcpServer( options: { diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/index.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/index.ts new file mode 100644 index 000000000000..9ddc23f68ddd --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { LIST_SCHEMATICS_TOOL } from './list-schematics'; +import { RUN_SCHEMATIC_TOOL } from './run-schematic'; + +export { LIST_SCHEMATICS_TOOL, RUN_SCHEMATIC_TOOL }; +export const SCHEMATICS_TOOLS = [LIST_SCHEMATICS_TOOL, RUN_SCHEMATIC_TOOL] as const; diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/list-schematics.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/list-schematics.ts new file mode 100644 index 000000000000..31f06c8f65b3 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/list-schematics.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { z } from 'zod'; +import { createStructuredContentOutput } from '../../utils'; +import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; +import { loadSchematicsMetadata } from './utils'; + +const listInputSchema = z.object({ + workspacePath: z + .string() + .describe('Path to the workspace angular.json (or any file within the workspace).'), +}); + +const listOutputSchema = z.object({ + schematics: z.array( + z.object({ + name: z.string(), + aliases: z.array(z.string()).optional(), + description: z.string().optional(), + hidden: z.boolean().optional(), + private: z.boolean().optional(), + required: z.array(z.string()).optional(), + options: z + .array( + z.object({ + name: z.string(), + description: z.string().optional(), + type: z.union([z.string(), z.array(z.string())]).optional(), + enum: z.array(z.any()).optional(), + default: z.any().optional(), + required: z.boolean().optional(), + alias: z.string().optional(), + prompt: z.string().optional(), + }), + ) + .optional(), + }), + ), +}); + +export type ListSchematicsInput = z.infer; +export type ListSchematicsOutput = z.infer; + +async function handleListSchematics(input: ListSchematicsInput, context: McpToolContext) { + // Always use injected loader if present (for tests) + const { meta } = await loadSchematicsMetadata( + input.workspacePath, + context.logger, + typeof context.schematicMetaLoader === 'function' ? context.schematicMetaLoader : undefined, + ); + const serialized = meta.map((m) => ({ + name: m.name, + aliases: m.aliases, + description: m.description, + hidden: m.hidden, + private: m.private, + required: m.required, + options: m.options?.map((o) => ({ + name: o.name, + description: o.description, + type: o.type, + enum: o.enum, + default: o.default, + required: o.required, + alias: o.alias, + prompt: o.prompt, + })), + })); + + return createStructuredContentOutput({ + schematics: serialized, + }); +} + +export const LIST_SCHEMATICS_TOOL: McpToolDeclaration< + typeof listInputSchema.shape, + typeof listOutputSchema.shape +> = declareTool({ + name: 'list_schematics', + title: 'List Angular Schematics', + description: ` + +Enumerates all schematics provided by @schematics/angular with full option metadata (types, defaults, enums, prompts, required flags). + + +* Discover generators available before planning code changes. +* Inspect required options for a schematic prior to execution. +* Provide intelligent suggestions to users based on option types and prompts. + + +* Resolution uses Node's module loader. If a schematic collection cannot be resolved, this tool returns an empty list. +* Hidden/private schematics are included for transparency. +* Use 'run_schematic' to actually execute a schematic after reviewing options here. + * Official docs: https://angular.dev/tools/cli/schematics and https://angular.dev/cli/generate + +`, + inputSchema: listInputSchema.shape, + outputSchema: listOutputSchema.shape, + isLocalOnly: true, + isReadOnly: true, + factory: (context) => (input) => handleListSchematics(input, context), +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/run-schematic.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/run-schematic.ts new file mode 100644 index 000000000000..d89748ace9f9 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/run-schematic.ts @@ -0,0 +1,525 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { dirname, join } from 'node:path'; +import { z } from 'zod'; +import { type Host, LocalWorkspaceHost } from '../../host'; +import { createStructuredContentOutput, findAngularJsonDir } from '../../utils'; +import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; +import type { SchematicMeta, SchematicMetaOption } from './types'; +import { + buildAlternativeCommandPreviews, + emitKebabCaseHints, + getSchematicDocLink, + inferRequiredOptions, + loadSchematicsMetadata, + toKebabCase, +} from './utils'; + +/** + * Runs the ng CLI command for a schematic and handles errors/logs. + * Uses host.runCommand and provides troubleshooting info on failure. + */ +async function runNgSchematicCommand( + host: Host, + angularRoot: string, + args: string[], +): Promise<{ logs: string[]; success: boolean; invoked: string }> { + let logs: string[] = []; + let success = false; + let invoked = `'ng' (PATH)`; + let ngBin: string | undefined; + const potentialBins = [ + join(angularRoot, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'), + join(angularRoot, 'node_modules', '.bin', 'ng'), + ]; + for (const p of potentialBins) { + if (host.existsSync(p)) { + ngBin = p; + break; + } + } + const isJsEntrypoint = ngBin?.endsWith('ng.js'); + const cmd = isJsEntrypoint ? 'node' : 'ng'; + const finalArgs = isJsEntrypoint ? [ngBin as string, ...args] : args; + invoked = ngBin ? ngBin : 'ng (PATH)'; + try { + const result = await host.runCommand(cmd, finalArgs, { cwd: angularRoot }); + logs = result.logs; + success = true; + } catch (e) { + if (e instanceof Error && 'logs' in e) { + const err = e as Error & { logs?: string[] }; + if (err.logs) { + logs = err.logs; + } + } + logs.push((e as Error).message); + logs.push(`Invocation attempted via ${invoked}`); + logs.push( + 'Troubleshooting: Ensure @angular/cli is installed locally (npm i -D @angular/cli) and node_modules/.bin is accessible.', + ); + } + + return { logs, success, invoked }; +} + +const runInputSchema = z.object({ + schematic: z.string().describe('Schematic name or alias to execute (e.g. "component" or "c").'), + workspacePath: z + .string() + .describe( + 'Path to an angular.json file OR a directory within the workspace from which to search upward for angular.json.', + ), + options: z + .record(z.any()) + .optional() + .describe('Options passed to the schematic. Keys must match schema property names or aliases.'), + prompt: z + .string() + .optional() + .describe( + 'Natural language user instruction that led to this schematic invocation (used for inferring missing required options).', + ), + previewOnly: z + .boolean() + .optional() + .describe( + 'If true, do not run the schematic; instead return the exact CLI command (with inferred options) that would be executed.', + ), +}); + +const runOutputSchema = z.object({ + runResult: z.object({ + schematic: z.string(), + success: z.boolean(), + message: z.string().optional(), + logs: z.array(z.string()).optional(), + hints: z.array(z.string()).optional(), + }), + instructions: z.array(z.string()).optional(), +}); + +export type RunSchematicInput = z.infer; +export type RunSchematicOutput = z.infer; + +export function normalizeOptions( + found: SchematicMeta, + inputOptions: Record, +): { normalized: Record; hints: string[] } { + const normalized = { ...inputOptions }; + const hints: string[] = []; + if (!found.options) { + return { normalized, hints }; + } + for (const optMeta of found.options) { + if (!(optMeta.name in normalized)) { + continue; + } + let val = normalized[optMeta.name]; + const enumResult = normalizeEnum(val, optMeta); + val = enumResult.value; + if (enumResult.hint) { + hints.push(enumResult.hint); + } + const boolResult = normalizeBoolean(val, optMeta); + val = boolResult.value; + if (boolResult.hint) { + hints.push(boolResult.hint); + } + const numResult = normalizeNumber(val, optMeta); + val = numResult.value; + if (numResult.hint) { + hints.push(numResult.hint); + } + const jsonResult = normalizeJson(val, optMeta); + val = jsonResult.value; + if (jsonResult.hint) { + hints.push(jsonResult.hint); + } + const arrResult = normalizeArray(val, optMeta); + val = arrResult.value; + if (arrResult.hint) { + hints.push(arrResult.hint); + } + normalized[optMeta.name] = val; + } + + return { normalized, hints }; +} + +async function resolveWorkspaceRoot( + input: RunSchematicInput, + host: Host, +): Promise<{ angularRoot?: string; searchDir: string }> { + const pathStat = await host.stat(input.workspacePath).catch(() => undefined); + const searchDir = pathStat?.isDirectory() ? input.workspacePath : dirname(input.workspacePath); + const angularRoot = findAngularJsonDir(searchDir, host) ?? undefined; + + return { angularRoot, searchDir }; +} + +async function executeSchematic(input: RunSchematicInput, host: Host, context: McpToolContext) { + let { meta } = await loadSchematicsMetadata( + input.workspacePath, + context.logger, + typeof context.schematicMetaLoader === 'function' ? context.schematicMetaLoader : undefined, + ); + if (meta.length === 0) { + // Fallback for unit tests: use a mock server-provided collection if available + if (typeof context.schematicMetaLoader === 'function') { + ({ meta } = await context.schematicMetaLoader()); + } + } + if (meta.length === 0) { + return createStructuredContentOutput({ + instructions: [ + "Could not load '@schematics/angular'. Ensure Angular CLI dependencies are installed (try 'npm install' or 'pnpm install').", + ], + runResult: { + schematic: input.schematic, + success: false, + message: 'Failed before execution: schematics metadata unavailable.', + }, + }); + } + const found = meta.find( + (m) => m.name === input.schematic || (m.aliases?.includes(input.schematic) ?? false), + ); + if (!found) { + const instructions = [ + `Schematic '${input.schematic}' not found.`, + 'Run `list_schematics` to view all available schematics.', + ]; + + return createStructuredContentOutput({ + instructions, + runResult: { + schematic: input.schematic, + success: false, + message: `Unknown schematic '${input.schematic}'.`, + }, + }); + } + // Unknown option names (non-fatal) + const providedOptions = Object.keys(input.options ?? {}); + if (providedOptions.length > 0 && found.options) { + const validNames = new Set(found.options.map((o) => o.name)); + const unknown = providedOptions.filter((k) => !validNames.has(k)); + if (unknown.length > 0) { + context.logger.warn( + `Unknown schematic option(s) for '${found.name}': ${unknown.join(', ')} (will still attempt execution).`, + ); + } + } + // Workspace root resolution (needed for inference logic). + // Prefer workspace context from the test server when available to avoid touching the real FS during unit tests. + let angularRoot: string | undefined; + const server = (context as unknown as { server?: { getWorkspaceContext?: () => unknown } }) + .server; + if (server && typeof server.getWorkspaceContext === 'function') { + try { + const workspaceCtx = server.getWorkspaceContext(); + if (workspaceCtx && typeof workspaceCtx === 'object' && 'workspacePath' in workspaceCtx) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + angularRoot = (workspaceCtx as any).workspacePath as string; + } + } catch { + // ignore and fallback + } + } + + if (!angularRoot) { + const resolved = await resolveWorkspaceRoot(input, host); + angularRoot = resolved.angularRoot; + } + + if (!angularRoot) { + return createStructuredContentOutput({ + instructions: [ + 'Could not locate angular.json by searching upward from the provided workspacePath.', + ], + runResult: { + schematic: input.schematic, + success: false, + message: 'Workspace root not found.', + }, + }); + } + + // Infer missing required options BEFORE validation if possible. + const inference = await inferRequiredOptions(found, angularRoot, input.prompt, { + ...(input.options ?? {}), + }); + + // Normalize + serialize options + const { normalized, hints } = normalizeOptions(found, inference.options); + const optionArgs = serializeOptions(normalized); + const schematicName = found.name; + const args: string[] = ['generate', `@schematics/angular:${schematicName}`, ...optionArgs]; + const previewCommand = `ng ${args.join(' ')}`; + const alternativeCommands = buildAlternativeCommandPreviews( + schematicName, + inference.options, + inference.nameCandidates, + ); + + // Add hint entries for any key whose original form differs from kebab-case flag. + // Use the normalized options for hint emission to reflect any coercions/normalization. + hints.push(...emitKebabCaseHints(normalized)); + + // Add per-schematic doc link once, not per option + const schematicDoc = getSchematicDocLink(schematicName); + if (!hints.some((h) => h.includes(schematicDoc))) { + hints.push(`Docs '${schematicName}': ${schematicDoc}`); + } + + if (input.previewOnly) { + const previewHints = [...(inference.hints ?? []), ...hints]; + + return createStructuredContentOutput({ + instructions: [ + 'Preview only (no execution). Review primary and alternative commands; rerun without previewOnly to execute.', + `Primary: ${previewCommand}`, + ...(alternativeCommands.length > 1 + ? ['Alternatives:', ...alternativeCommands.filter((c) => c !== previewCommand)] + : []), + ], + runResult: { + schematic: schematicName, + success: false, + message: 'Preview mode: schematic not executed.', + logs: [], + hints: previewHints, + }, + }); + } + + // If not preview, abort early when required options are still missing + if (inference.missingAfter.length > 0) { + return createStructuredContentOutput({ + instructions: [ + `Missing required option(s) for schematic '${found.name}': ${inference.missingAfter.join(', ')}`, + 'Provide all required options. Use list_schematics to inspect option metadata.', + ], + runResult: { + schematic: found.name, + success: false, + message: `Aborted before execution: missing required option(s): ${inference.missingAfter.join(', ')}`, + logs: [ + `Required: ${inference.missingAfter.join(', ')}`, + 'No process spawned; validation failed early.', + ], + hints: inference.hints.length ? inference.hints : undefined, + }, + }); + } + + // Execution + const { logs, success, invoked } = await runNgSchematicCommand(host, angularRoot, args); + + return createStructuredContentOutput({ + instructions: [ + `About to execute primary: ${previewCommand}`, + ...(alternativeCommands.length > 1 + ? [ + 'Name alternatives (adjust --name to use):', + ...alternativeCommands.filter((c) => c !== previewCommand), + ] + : []), + ], + runResult: { + schematic: schematicName, + success, + message: success + ? `Executed schematic '${schematicName}' successfully.` + : `Failed to execute schematic '${schematicName}'.`, + logs, + hints: [...inference.hints, ...hints].length ? [...inference.hints, ...hints] : undefined, + }, + }); +} + +export const RUN_SCHEMATIC_TOOL: McpToolDeclaration< + typeof runInputSchema.shape, + typeof runOutputSchema.shape +> = declareTool({ + name: 'run_schematic', + title: 'Run an Angular Schematic', + description: ` + +Executes a single Angular schematic (generator) from @schematics/angular with user-supplied options. + + +* Generate components, directives, pipes, services, modules, libraries, etc. +* Apply refactor schematics (e.g. jasmine-to-vitest) after inspecting options. + + +* **Project-Specific Use (Recommended):** Provide the workspacePath argument to get the collection matching the project's Angular version. +* Always call 'list_schematics' first to validate the schematic name and review option types. +* Options serialization follows CLI conventions: arrays of primitives repeat the flag; complex values are JSON. +* Logs include both stdout and stderr merged in order. + * CamelCase schema option keys are automatically converted to kebab-case CLI flags (e.g. skipImport -> --skip-import). + * Official docs: https://angular.dev/tools/cli/schematics and https://angular.dev/cli/generate + +`, + inputSchema: runInputSchema.shape, + outputSchema: runOutputSchema.shape, + isLocalOnly: true, + isReadOnly: false, + factory: (context) => (input) => executeSchematic(input, LocalWorkspaceHost, context), +}); + +function serializeOptions(options: Record): string[] { + const args: string[] = []; + for (const [rawKey, value] of Object.entries(options)) { + if (value === undefined || value === null) { + continue; + } + const key = toKebabCase(rawKey); + if (typeof value === 'boolean') { + args.push(`--${key}=${value}`); + } else if (Array.isArray(value)) { + if (value.every((v) => ['string', 'number', 'boolean'].includes(typeof v))) { + for (const v of value) { + args.push(`--${key}=${String(v)}`); + } + } else { + args.push(`--${key}=${JSON.stringify(value)}`); + } + } else if (typeof value === 'object') { + args.push(`--${key}=${JSON.stringify(value)}`); + } else { + args.push(`--${key}=${String(value)}`); + } + } + + return args; +} + +/* Option normalization helpers for schematic input values. */ + +function normalizeEnum( + val: unknown, + optMeta: SchematicMetaOption, +): { value: unknown; hint?: string } { + if ( + optMeta.enum && + typeof val === 'string' && + !optMeta.enum.includes(val) && + optMeta.enum.some((e: unknown) => typeof e === 'string') + ) { + const lower = val.toLowerCase(); + const match = optMeta.enum.find( + (e: unknown) => typeof e === 'string' && e.toLowerCase() === lower, + ); + if (match) { + return { + value: match, + hint: `Option '${optMeta.name}': coerced value '${val}' to enum '${match}' (case-insensitive match)`, + }; + } + } + + return { value: val }; +} + +function normalizeBoolean( + val: unknown, + optMeta: SchematicMetaOption, +): { value: unknown; hint?: string } { + if (Array.isArray(optMeta.type) ? optMeta.type.includes('boolean') : optMeta.type === 'boolean') { + if (typeof val === 'string') { + const lower = val.toLowerCase(); + if (['true', 'false', '1', '0', 'yes', 'no'].includes(lower)) { + const boolVal = ['true', '1', 'yes'].includes(lower); + + return { + value: boolVal, + hint: `Option '${optMeta.name}': coerced string '${val}' -> boolean ${boolVal}`, + }; + } + } + } + + return { value: val }; +} + +function normalizeNumber( + val: unknown, + optMeta: SchematicMetaOption, +): { value: unknown; hint?: string } { + if ( + (Array.isArray(optMeta.type) + ? optMeta.type.includes('number') || optMeta.type.includes('integer') + : optMeta.type === 'number' || optMeta.type === 'integer') && + typeof val === 'string' + ) { + if (/^[+-]?\d+(\.\d+)?$/.test(val)) { + const num = Number(val); + if (!Number.isNaN(num)) { + return { + value: num, + hint: `Option '${optMeta.name}': coerced string '${val}' -> number ${num}`, + }; + } + } + } + + return { value: val }; +} + +function normalizeJson( + val: unknown, + optMeta: SchematicMetaOption, +): { value: unknown; hint?: string } { + if ( + (Array.isArray(optMeta.type) + ? optMeta.type.includes('object') || optMeta.type.includes('array') + : optMeta.type === 'object' || optMeta.type === 'array') && + typeof val === 'string' && + /^[[{].*[}]]$/.test(val.trim()) + ) { + try { + const parsed = JSON.parse(val); + + return { + value: parsed, + hint: `Option '${optMeta.name}': parsed JSON string into ${Array.isArray(parsed) ? 'array' : 'object'}`, + }; + } catch { + // ignore parse error + } + } + + return { value: val }; +} + +function normalizeArray( + val: unknown, + optMeta: SchematicMetaOption, +): { value: unknown; hint?: string } { + if ( + (Array.isArray(optMeta.type) ? optMeta.type.includes('array') : optMeta.type === 'array') && + typeof val === 'string' && + val.includes(',') + ) { + const split = val + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (split.length > 1) { + return { + value: split, + hint: `Option '${optMeta.name}': split comma-delimited string into array [${split.join(', ')}]`, + }; + } + } + + return { value: val }; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/schematics_spec.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/schematics_spec.ts new file mode 100644 index 000000000000..34dd7e2e2b2a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/schematics_spec.ts @@ -0,0 +1,418 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { McpToolContext } from '../tool-registry'; +import { LIST_SCHEMATICS_TOOL } from './list-schematics'; +import { RUN_SCHEMATIC_TOOL, normalizeOptions } from './run-schematic'; +import { emitKebabCaseHints, getSchematicDocLink } from './utils'; + +// Shared mocks for MCP tool tests +// Mock schematic collection and workspace context +const mockSchematicCollection = { + schematics: { + component: { + name: 'component', + aliases: ['c'], + options: [ + { name: 'name', type: 'string', required: true }, + { name: 'skipImport', type: 'boolean' }, + { name: 'fooBar', type: 'number' }, + ], + required: ['name'], + hidden: false, + private: false, + }, + directive: { + name: 'directive', + aliases: ['d'], + options: [{ name: 'name', type: 'string', required: true }], + required: ['name'], + hidden: false, + private: false, + }, + hiddenSchematic: { + name: 'hiddenSchematic', + aliases: ['h'], + options: [{ name: 'name', type: 'string', required: true }], + required: ['name'], + hidden: true, + private: false, + }, + privateSchematic: { + name: 'privateSchematic', + aliases: ['p'], + options: [{ name: 'name', type: 'string', required: true }], + required: ['name'], + hidden: false, + private: true, + }, + }, +}; + +// Loader function for dependency injection +function mockSchematicMetaLoader() { + return Promise.resolve({ + meta: Object.values(mockSchematicCollection.schematics), + }); +} + +const mockWorkspaceContext = { + workspacePath: '.', + collection: mockSchematicCollection, + schematicMetaLoader: mockSchematicMetaLoader, +}; + +const mockServer = { + server: {} as unknown, + connect: () => {}, + close: () => {}, + _toolHandlersInitialized: false, + on: () => {}, + emit: () => {}, + registerTool: () => {}, + registerResource: () => {}, + registerResourceTemplate: () => {}, + sendNotification: () => Promise.resolve(), + sendRequest: () => Promise.resolve({}), + // Mock schematic collection and workspace context for tool logic + getSchematicCollection: () => mockSchematicCollection, + getWorkspaceContext: () => mockWorkspaceContext, + schematicMetaLoader: mockSchematicMetaLoader, + setToolRequestHandlers: () => {}, + createToolError: () => {}, + _completionHandlerInitialized: false, + setCompletionRequestHandler: () => {}, + _resourceHandlersInitialized: false, + setResourceRequestHandlers: () => {}, + setPromptRequestHandlers: () => {}, + setResourceCompletionHandler: () => {}, + setPromptCompletionHandler: () => {}, + setToolCompletionHandler: () => {}, + _toolCompletionHandlerInitialized: false, + _promptCompletionHandlerInitialized: false, + _resourceCompletionHandlerInitialized: false, + handlePromptCompletion: () => {}, + handleResourceCompletion: () => {}, + _promptHandlersInitialized: false, + resource: {}, + prompt: {}, + tool: {}, + _resourceHandlers: [], + _promptHandlers: [], + _toolHandlers: [], + _resourceCompletionHandler: () => {}, + _promptCompletionHandler: () => {}, + _toolCompletionHandler: () => {}, + _createRegisteredResource: () => {}, + _createRegisteredResourceTemplate: () => {}, + _createRegisteredPrompt: () => {}, + _createRegisteredTool: () => {}, + _resource: {}, + _prompt: {}, + _tool: {}, + registerPrompt: () => {}, + isConnected: () => true, + sendLoggingMessage: () => {}, + sendResourceListChanged: () => {}, + sendPromptListChanged: () => {}, + sendToolListChanged: () => {}, +} as unknown as McpServer; + +const mockExtra = { + signal: new AbortController().signal, + requestId: 'test', + sendNotification: () => Promise.resolve(), + sendRequest: () => Promise.resolve({}), +}; + +/** + * Schematics MCP tool tests + * + * Covers: run_schematic, list_schematics, option normalization, kebab-case flags, doc links, and error handling. + */ +describe('Schematics MCP Tools', () => { + it('getSchematicDocLink returns correct per-schematic doc URL', () => { + expect(getSchematicDocLink('component')).toBe('https://angular.dev/cli/generate/component'); + expect(getSchematicDocLink('directive')).toBe('https://angular.dev/cli/generate/directive'); + }); + + it('emitKebabCaseHints emits hints for camelCase keys', () => { + const opts = { skipImport: true, flat: false, fooBarBaz: 1 }; + const hints = emitKebabCaseHints(opts); + expect(hints).toContain("Option 'skipImport' emitted as '--skip-import'"); + }); + + it('normalizeOptions normalizes booleans, numbers, enums, arrays', () => { + const meta = { + name: 'test', + options: [ + { name: 'flag', type: 'boolean' }, + { name: 'count', type: 'number' }, + { name: 'mode', type: 'string', enum: ['fast', 'slow'] }, + { name: 'tags', type: 'array' }, + ], + }; + const input = { + flag: 'true', + count: '42', + mode: 'FAST', + tags: 'a,b,c', + }; + const { normalized, hints } = normalizeOptions(meta, input); + expect(normalized.flag).toBe(true); + expect(normalized.count).toBe(42); + expect(normalized.tags).toEqual(['a', 'b', 'c']); + // Check that hints include coercion info + expect(hints.some((h) => h.includes('coerced'))).toBe(true); + }); + + describe('run_schematic', () => { + it('fails with unknown schematic', async () => { + const input = { schematic: 'notarealschematic', workspacePath: '.', options: {} }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const raw = await toolCallback(input, mockExtra); + const text = typeof raw.content?.[0]?.text === 'string' ? raw.content[0].text : '{}'; + // Type guard for JSON.parse + const result = typeof text === 'string' ? JSON.parse(text) : {}; + expect(result.runResult.success).toBe(false); + expect(Array.isArray(result.instructions)).toBe(true); + }); + + it('fails with missing required options', async () => { + const input = { schematic: 'component', workspacePath: '.', options: {} }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const raw = await toolCallback(input, mockExtra); + const text = typeof raw.content?.[0]?.text === 'string' ? raw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(result.runResult.success).toBe(false); + expect(Array.isArray(result.instructions)).toBe(true); + expect(result.instructions?.some((i: string) => i.includes('Missing required option'))).toBe( + true, + ); + }); + + it('converts camelCase options to kebab-case flags in preview', async () => { + const input = { + schematic: 'component', + workspacePath: '.', + options: { skipImport: true, fooBar: 1 }, + previewOnly: true, + }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const raw = await toolCallback(input, mockExtra); + const text = typeof raw.content?.[0]?.text === 'string' ? raw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(Array.isArray(result.runResult.hints)).toBe(true); + expect(result.runResult.hints?.some((h: string) => h.includes('--skip-import'))).toBe(true); + expect(result.runResult.hints?.some((h: string) => h.includes('--foo-bar'))).toBe(true); + }); + + it('emits per-schematic doc link in hints', async () => { + const input = { + schematic: 'component', + workspacePath: '.', + options: { name: 'test' }, + previewOnly: true, + }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const raw = await toolCallback(input, mockExtra); + const text = typeof raw.content?.[0]?.text === 'string' ? raw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(Array.isArray(result.runResult.hints)).toBe(true); + expect( + result.runResult.hints?.some((h: string) => + h.includes('https://angular.dev/cli/generate/component'), + ), + ).toBe(true); + }); + + it('handles unknown option keys gracefully', async () => { + const input = { + schematic: 'component', + workspacePath: '.', + options: { name: 'test', unknownOpt: 123 }, + previewOnly: true, + }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const raw = await toolCallback(input, mockExtra); + const text = typeof raw.content?.[0]?.text === 'string' ? raw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(result.runResult.success).toBe(false); + expect(Array.isArray(result.runResult.hints)).toBe(true); + }); + + it('previewOnly returns command and does not execute', async () => { + const input = { + schematic: 'component', + workspacePath: '.', + options: { name: 'test' }, + previewOnly: true, + }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(result.runResult.success).toBe(false); + expect(Array.isArray(result.instructions)).toBe(true); + expect(result.instructions?.some((i: string) => i.includes('Preview only'))).toBe(true); + }); + + it('infers required options from prompt', async () => { + const input = { + schematic: 'component', + workspacePath: '.', + options: {}, + prompt: 'Create a component called MyTest in src/app', + }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await RUN_SCHEMATIC_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = JSON.parse(text); + expect(Array.isArray(result.runResult.hints)).toBe(true); + expect( + result.runResult.hints?.some((h: string) => h.includes('Inferred required option')), + ).toBe(true); + }); + }); + + describe('list_schematics', () => { + it('returns all available schematics with metadata', async () => { + const input = { workspacePath: '.' }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await LIST_SCHEMATICS_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = typeof text === 'string' ? JSON.parse(text) : {}; + expect(Array.isArray(result.schematics)).toBe(true); + expect(result.schematics.length).toBeGreaterThan(0); + expect(result.schematics.some((s: { name?: string }) => typeof s.name === 'string')).toBe( + true, + ); + expect(result.schematics.some((s: { options?: unknown }) => s.options !== undefined)).toBe( + true, + ); + }); + + it('includes aliases, required options, and option types', async () => { + const input = { workspacePath: '.' }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await LIST_SCHEMATICS_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = typeof text === 'string' ? JSON.parse(text) : {}; + // Use a more specific type for schematic + const schematic = Array.isArray(result.schematics) + ? result.schematics.find((s: { name?: string }) => s.name === 'component') + : undefined; + expect(schematic).toBeDefined(); + if (schematic) { + expect(Array.isArray(schematic.aliases)).toBe(true); + expect(Array.isArray(schematic.required)).toBe(true); + expect(Array.isArray(schematic.options)).toBe(true); + if (schematic.options && schematic.options.length > 0) { + expect(typeof schematic.options[0].type).toBe('string'); + } + } + }); + + it('includes hidden/private schematics', async () => { + const input = { workspacePath: '.' }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: mockSchematicMetaLoader, + }; + const toolCallback = await LIST_SCHEMATICS_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = typeof text === 'string' ? JSON.parse(text) : {}; + const hasHiddenOrPrivate = + Array.isArray(result.schematics) && + result.schematics.some( + (s: { hidden?: boolean; private?: boolean }) => s.hidden === true || s.private === true, + ); + expect(hasHiddenOrPrivate).toBe(true); + }); + + it('handles missing or invalid workspace gracefully', async () => { + const input = { workspacePath: 'not-a-real-path' }; + const context: McpToolContext = { + server: mockServer, + devServers: new Map(), + logger: { warn: () => {} }, + schematicMetaLoader: async () => ({ meta: [] }), + }; + const toolCallback = await LIST_SCHEMATICS_TOOL.factory(context); + const resultRaw = await toolCallback(input, mockExtra); + const text = + typeof resultRaw.content?.[0]?.text === 'string' ? resultRaw.content[0].text : '{}'; + const result = typeof text === 'string' ? JSON.parse(text) : {}; + expect(Array.isArray(result.schematics)).toBe(true); + expect(result.schematics.length).toBe(0); + }); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/types.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/types.ts new file mode 100644 index 000000000000..8d47cd1aa1b0 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/types.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export interface InferenceResult { + options: Record; + hints: string[]; + missingAfter: string[]; + nameCandidates: string[]; +} + +export interface SchematicMetaOption { + name: string; + description?: string; + type?: string | string[]; + enum?: unknown[]; + default?: unknown; + required?: boolean; + alias?: string; + oneOf?: unknown[]; + prompt?: string; +} + +export interface SchematicMeta { + name: string; + aliases?: string[]; + description?: string; + hidden?: boolean; + private?: boolean; + options?: SchematicMetaOption[]; + required?: string[]; +} + +export interface ResolutionInfo { + resolved?: string; + candidateDir: string; + strategy: string; + anchor?: string; + fallback?: boolean; + error?: string; +} + +interface CollectionEntryRaw { + aliases?: string[]; + description?: string; + hidden?: boolean; + private?: boolean; + schema?: string; +} + +export interface CollectionJson { + schematics: Record; +} + +export interface SchemaShape { + properties?: Record; + required?: string[]; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/schematics/utils.ts b/packages/angular/cli/src/commands/mcp/tools/schematics/utils.ts new file mode 100644 index 000000000000..cdfaf164f00e --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/schematics/utils.ts @@ -0,0 +1,446 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import type { McpToolContext } from '../tool-registry'; +import type { + CollectionJson, + InferenceResult, + ResolutionInfo, + SchemaShape, + SchematicMeta, + SchematicMetaOption, +} from './types'; + +// In-memory cache with TTL + mtime validation. +const SCHEMATICS_CACHE = new Map< + string, + { + meta: SchematicMeta[]; + modifiedTimeMs: number; + cachedAt: number; + } +>(); + +const SCHEMATICS_CACHE_TTL_MS = 30_000; // 30s TTL + +export function toKebabCase(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[^A-Za-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +export async function inferRequiredOptions( + found: SchematicMeta, + angularRoot: string, + prompt: string | undefined, + original: Record, +): Promise { + const working = { ...original }; + const hints: string[] = []; + const nameCandidates: string[] = []; + if (!found.options || !found.options.length) { + return { options: working, hints, missingAfter: [], nameCandidates }; + } + const requiredNames = found.options.filter((o) => o.required).map((o) => o.name); + const missing = requiredNames.filter( + (r) => !(r in working) || working[r] === undefined || working[r] === '', + ); + if (missing.length) { + if (missing.includes('project')) { + try { + const angularJsonRaw = await readFile(join(angularRoot, 'angular.json'), 'utf-8'); + const angularConfig = JSON.parse(angularJsonRaw) as { + defaultProject?: string; + projects?: Record; + }; + let inferred = angularConfig.defaultProject; + if (!inferred && angularConfig.projects) { + inferred = Object.entries(angularConfig.projects).find( + ([, _v]) => _v.projectType === 'application', + )?.[0]; + if (!inferred) { + inferred = Object.keys(angularConfig.projects)[0]; + } + } + if ( + !inferred && + angularConfig.projects && + Object.keys(angularConfig.projects).length === 1 + ) { + inferred = Object.keys(angularConfig.projects)[0]; + } + if (inferred) { + working['project'] = inferred; + hints.push(`Inferred required option 'project' = '${inferred}'`); + } + } catch { + // ignore + } + } + if (missing.includes('name')) { + let rawName: string | undefined; + if (prompt) { + rawName = extractNameFromPrompt(prompt, found.name); + } + const candidates = buildNameCandidates(rawName, found.name); + nameCandidates.push(...candidates); + if (candidates.length) { + const chosen = toKebabCase(candidates[0]) || candidates[0]; + working['name'] = chosen; + hints.push( + `Inferred required option 'name' primary='${working['name']}' candidates=[${candidates.join(', ')}]`, + ); + } + } + if (missing.includes('path') && prompt) { + const extractedPath = extractPathFromPrompt(prompt); + if (extractedPath) { + working['path'] = extractedPath; + hints.push(`Inferred option 'path' = '${extractedPath}' from natural language prompt`); + } + } + } + const stillMissing = requiredNames.filter( + (r) => !(r in working) || working[r] === undefined || working[r] === '', + ); + + return { options: working, hints, missingAfter: stillMissing, nameCandidates }; +} + +export function buildAlternativeCommandPreviews( + schematicName: string, + baseOptions: Record, + nameCandidates: string[], +): string[] { + if (!nameCandidates.length) { + return []; + } + const previews: string[] = []; + for (const candidate of nameCandidates.slice(0, 6)) { + const opts = { ...baseOptions, name: toKebabCase(candidate) || candidate }; + const args = Object.entries(opts) + .filter(([_, v]) => v !== undefined && v !== null && v !== '') + .map( + ([k, v]) => `--${k}=${Array.isArray(v) || typeof v === 'object' ? JSON.stringify(v) : v}`, + ); + previews.push(`ng generate @schematics/angular:${schematicName} ${args.join(' ')}`.trim()); + } + + return [...new Set(previews)]; +} + +/** + * Returns the Angular docs URL for a schematic. + */ +export function getSchematicDocLink(schematicName: string): string { + return `https://angular.dev/cli/generate/${schematicName}`; +} + +/** + * Emits hints for option keys that are converted to kebab-case (for CLI compatibility). + */ +export function emitKebabCaseHints(options: Record): string[] { + const hints: string[] = []; + for (const k of Object.keys(options)) { + if (options[k] === undefined || options[k] === null) { + continue; + } + const kebab = toKebabCase(k); + if (k !== kebab) { + hints.push(`Option '${k}' emitted as '--${kebab}'`); + } + } + + return hints; +} + +export async function loadSchematicsMetadata( + workspacePath: string, + logger: McpToolContext['logger'], + loader?: () => Promise<{ meta: SchematicMeta[] }>, +): Promise<{ meta: SchematicMeta[] }> { + // If a loader is provided (e.g. in tests), use it + if (loader) { + return loader(); + } + // Fallback to real file resolution if no mock is present + const info = resolveCollection(workspacePath); + if (!info.resolved) { + logger.warn( + `Could not resolve '@schematics/angular/collection.json' from '${workspacePath}'. Error: ${info.error}`, + ); + + return Promise.resolve({ meta: [] }); + } + logger.warn( + 'Schematics collection path: ' + info.resolved + (info.fallback ? ' (fallback)' : ''), + ); + const collectionPath = info.resolved; + try { + const fileStat = await stat(collectionPath); + const now = Date.now(); + const cached = SCHEMATICS_CACHE.get(collectionPath); + if ( + cached && + cached.modifiedTimeMs === fileStat.mtimeMs && + now - cached.cachedAt < SCHEMATICS_CACHE_TTL_MS && + !info.fallback + ) { + return Promise.resolve({ meta: cached.meta }); + } + } catch { + // ignore stat errors + } + const content = await readFile(collectionPath, 'utf-8'); + const json: CollectionJson = JSON.parse(content); + const baseDir = dirname(collectionPath); + const meta: SchematicMeta[] = []; + for (const [name, entry] of Object.entries(json.schematics)) { + const schematicMeta: SchematicMeta = { + name, + aliases: entry.aliases, + description: entry.description, + hidden: entry.hidden, + private: entry.private, + }; + if (entry.schema) { + try { + const schemaStr = await readFile(join(baseDir, entry.schema), 'utf-8'); + const schemaJson: SchemaShape = JSON.parse(schemaStr); + schematicMeta.required = Array.isArray(schemaJson.required) ? schemaJson.required : []; + const props = schemaJson.properties || {}; + schematicMeta.options = Object.entries(props).map(([propName, def]) => { + const record = def as Record; + const promptVal = record['x-prompt']; + + return { + name: propName, + description: record.description as string | undefined, + type: record.type as string | string[] | undefined, + enum: record.enum as unknown[] | undefined, + default: record.default, + required: schematicMeta.required?.includes(propName), + alias: record.alias as string | undefined, + oneOf: record.oneOf as unknown[] | undefined, + prompt: + typeof promptVal === 'string' + ? promptVal + : typeof promptVal === 'object' && promptVal !== null + ? JSON.stringify(promptVal) + : undefined, + } as SchematicMetaOption; + }); + } catch { + // ignore schema read errors + } + } + meta.push(schematicMeta); + } + try { + const fileStat = await stat(collectionPath); + SCHEMATICS_CACHE.set(collectionPath, { + meta, + modifiedTimeMs: fileStat.mtimeMs, + cachedAt: Date.now(), + }); + } catch { + // ignore stat errors + } + + return Promise.resolve({ meta }); +} + +function truncateAtConnectors(text: string): string { + const stopPattern = + /(\busing\b|\bwith\b|\bthat\b|\bwhich\b|\bin\b|\bat\b|\bunder\b|\binside\b|\bwithin\b|\bwhen\b|\bwhile\b)/i; + const parts = text.split(stopPattern); + + return parts.length > 1 ? parts[0].trim() : text.trim(); +} + +function extractPathFromPrompt(prompt: string): string | undefined { + const directPatterns = [ + /\b(?:at|in|into|under|inside|within)\s+(["'])([^"']+)\1/gi, + /\b(?:at|in|into|under|inside|within)\s+([^.,;]+)/gi, + ]; + for (const pattern of directPatterns) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(prompt)) !== null) { + const raw = match[2] ?? match[1]; + if (!raw) { + continue; + } + const truncated = truncateAtConnectors(raw) + .split(/\b(for|to|from|so|and|but|because)\b/i)[0] + .trim(); + let candidate = truncated + .replace(/\b(folder|directory|subdirectory|sub-folder)\b/gi, ' ') + .replace(/\b(the|this|that|a|an)\b/gi, ' ') + .replace(/["']/g, ' ') + .replace(/\s*(?:\/|\\)\s*/g, '/') + .trim(); + + if (!candidate) { + continue; + } + if (candidate.includes('/')) { + candidate = candidate + .split('/') + .map((segment) => segment.trim().replace(/\s+/g, '-')) + .filter((segment) => segment.length > 0) + .join('/'); + } else { + candidate = candidate.replace(/\s+/g, '-'); + } + candidate = candidate + .replace(/-{2,}/g, '-') + .replace(/\/-{1,}/g, '/') + .replace(/-{1,}\//g, '/') + .replace(/\/{2,}/g, '/') + .replace(/^\//, '') + .replace(/\/$/, ''); + if (!candidate.length) { + continue; + } + + return candidate; + } + } + + return undefined; +} + +function extractNameFromPrompt(prompt: string, schematicType: string): string | undefined { + const calledMatch = prompt.match(/\b(called|named)\s+([A-Za-z][\w-]*)/i); + if (calledMatch) { + return calledMatch[2]; + } + const typeNamedPattern = new RegExp( + `\\b${schematicType}\\b\\s+(?:called|named)\\s+([A-Za-z][A-Za-z0-9_-]*)`, + 'i', + ); + const typeNamedMatch = prompt.match(typeNamedPattern); + if (typeNamedMatch) { + return typeNamedMatch[1]; + } + const typeLeadingPattern = new RegExp( + `\\b${schematicType}\\b[^A-Za-z0-9]+([A-Za-z][A-Za-z0-9_-]*)`, + 'i', + ); + const typeLeadingMatch = prompt.match(typeLeadingPattern); + if (typeLeadingMatch) { + return typeLeadingMatch[1]; + } + const descriptivePattern = new RegExp( + `\\b${schematicType}\\b\\s+(?:for|to|about)\\s+([^.,;]+)`, + 'i', + ); + const descriptiveMatch = prompt.match(descriptivePattern); + if (descriptiveMatch) { + const truncated = truncateAtConnectors(descriptiveMatch[1]); + const cleaned = truncated + .replace(/\b(the|a|an|new|component|enum|service|pipe|directive|module|class|file)\b/gi, ' ') + .trim(); + if (cleaned) { + const tokens = cleaned + .split(/[\s_-]+/) + .filter((segment) => segment.length > 0) + .slice(0, 6); + if (tokens.length) { + return tokens + .map((segment, index) => + index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(''); + } + } + } + + return undefined; +} + +function buildNameCandidates(raw: string | undefined, _schematicType: string): string[] { + const base = raw?.trim(); + const candidates: string[] = []; + if (base) { + const noSpaces = base.replace(/\s+/g, '-'); + candidates.push(base); + candidates.push(noSpaces); + const kebab = toKebabCase(base); + candidates.push(kebab); + // PascalCase & camelCase versions (from tokens) + const parts = base + .replace(/[^A-Za-z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + if (parts.length) { + const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(''); + const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1); + candidates.push(pascal); + candidates.push(camel); + } + } + // No generic fallback names: if prompt gives nothing we return empty list so caller can surface a missing required option. + // Deduplicate preserving order & remove empties. + + return [...new Set(candidates.filter((c) => c && c.length))].slice(0, 8); +} + +function resolveCollection(workspacePath: string): ResolutionInfo { + const baseDir = workspacePath.endsWith('.json') ? dirname(workspacePath) : workspacePath; + const candidatePackageJson = join(baseDir, 'package.json'); + const anchors: string[] = []; + if (candidatePackageJson) { + anchors.push(candidatePackageJson); + } + anchors.push(join(baseDir, 'angular.json')); + anchors.push(join(baseDir, 'index.mcp-anchor.js')); + for (const anchor of anchors) { + try { + const req = createRequire(anchor); + try { + const direct = req.resolve('@schematics/angular/collection.json'); + + return { resolved: direct, candidateDir: baseDir, strategy: 'direct', anchor }; + } catch { + const pkgRoot = req.resolve('@schematics/angular/package.json'); + const rootDir = dirname(pkgRoot); + + return { + resolved: join(rootDir, 'collection.json'), + candidateDir: baseDir, + strategy: 'pkg-root', + anchor, + }; + } + } catch { + // continue + } + } + try { + const globalPkg = require.resolve('@schematics/angular/package.json'); + const globalRoot = dirname(globalPkg); + + return { + resolved: join(globalRoot, 'collection.json'), + candidateDir: baseDir, + fallback: true, + strategy: 'global-pkg-root', + error: 'Local resolution failed with all anchors.', + }; + } catch (e) { + return { error: (e as Error).message, candidateDir: baseDir, strategy: 'failed' }; + } +} diff --git a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts index 9bbce768000b..3e3f9bf83dc6 100644 --- a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts +++ b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts @@ -10,6 +10,7 @@ import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/m import type { ZodRawShape } from 'zod'; import type { AngularWorkspace } from '../../../utilities/config'; import type { DevServer } from '../dev-server'; +import type { SchematicMeta } from './schematics/types'; type ToolConfig = Parameters[1]; @@ -19,6 +20,7 @@ export interface McpToolContext { logger: { warn(text: string): void }; exampleDatabasePath?: string; devServers: Map; + schematicMetaLoader?: () => Promise<{ meta: SchematicMeta[] }>; } export type McpToolFactory = (