From 63dd8c2aa63eddb16bc47bdc9a2ac4fd4da04ef0 Mon Sep 17 00:00:00 2001 From: Alon Mishne Date: Thu, 10 Jul 2025 16:18:54 -0700 Subject: [PATCH] feat(@angular/cli): add modernize tool to the MCP server - Adds a new `modernize` tool to the MCP server. This tool provides developers with instructions for running various Angular migrations, such as control-flow, standalone, and signal inputs. - Includes a comprehensive list of available modernizations, with links to their documentation. - Adds unit tests for the new `modernize` tool to ensure its correctness. - Refactors the static best-practices guide into a dedicated `instructions.ts` resource for better code organization. --- .../cli/src/commands/mcp/mcp-server.ts | 26 +-- .../best-practices.md | 0 .../commands/mcp/resources/instructions.ts | 32 ++++ .../cli/src/commands/mcp/tools/modernize.ts | 162 ++++++++++++++++++ .../src/commands/mcp/tools/modernize_spec.ts | 73 ++++++++ 5 files changed, 271 insertions(+), 22 deletions(-) rename packages/angular/cli/src/commands/mcp/{instructions => resources}/best-practices.md (100%) create mode 100644 packages/angular/cli/src/commands/mcp/resources/instructions.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/modernize.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 0da9f1559040..f20355f04f7b 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -11,9 +11,11 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; +import { registerInstructionsResource } from './resources/instructions'; import { registerBestPracticesTool } from './tools/best-practices'; import { registerDocSearchTool } from './tools/doc-search'; import { registerFindExampleTool } from './tools/examples'; +import { registerModernizeTool } from './tools/modernize'; import { registerListProjectsTool } from './tools/projects'; export async function createMcpServer( @@ -34,29 +36,9 @@ export async function createMcpServer( 'When writing or modifying Angular code, use the MCP server and its tools instead of direct shell commands where possible.', }); - server.registerResource( - 'instructions', - 'instructions://best-practices', - { - title: 'Angular Best Practices and Code Generation Guide', - description: - "A comprehensive guide detailing Angular's best practices for code generation and development." + - ' This guide should be used as a reference by an LLM to ensure any generated code' + - ' adheres to modern Angular standards, including the use of standalone components,' + - ' typed forms, modern control flow syntax, and other current conventions.', - mimeType: 'text/markdown', - }, - async () => { - const text = await readFile( - path.join(__dirname, 'instructions', 'best-practices.md'), - 'utf-8', - ); - - return { contents: [{ uri: 'instructions://best-practices', text }] }; - }, - ); - + registerInstructionsResource(server); registerBestPracticesTool(server); + registerModernizeTool(server); // If run outside an Angular workspace (e.g., globally) skip the workspace specific tools. if (context.workspace) { diff --git a/packages/angular/cli/src/commands/mcp/instructions/best-practices.md b/packages/angular/cli/src/commands/mcp/resources/best-practices.md similarity index 100% rename from packages/angular/cli/src/commands/mcp/instructions/best-practices.md rename to packages/angular/cli/src/commands/mcp/resources/best-practices.md diff --git a/packages/angular/cli/src/commands/mcp/resources/instructions.ts b/packages/angular/cli/src/commands/mcp/resources/instructions.ts new file mode 100644 index 000000000000..f2a0ac814b0a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/resources/instructions.ts @@ -0,0 +1,32 @@ +/** + * @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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export function registerInstructionsResource(server: McpServer): void { + server.registerResource( + 'instructions', + 'instructions://best-practices', + { + title: 'Angular Best Practices and Code Generation Guide', + description: + "A comprehensive guide detailing Angular's best practices for code generation and development." + + ' This guide should be used as a reference by an LLM to ensure any generated code' + + ' adheres to modern Angular standards, including the use of standalone components,' + + ' typed forms, modern control flow syntax, and other current conventions.', + mimeType: 'text/markdown', + }, + async () => { + const text = await readFile(path.join(__dirname, 'best-practices.md'), 'utf-8'); + + return { contents: [{ uri: 'instructions://best-practices', text }] }; + }, + ); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize.ts b/packages/angular/cli/src/commands/mcp/tools/modernize.ts new file mode 100644 index 000000000000..4f8ab5fb9170 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/modernize.ts @@ -0,0 +1,162 @@ +/** + * @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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +interface Transformation { + name: string; + description: string; + documentationUrl: string; + instructions?: string; +} + +const TRANSFORMATIONS: Array = [ + { + name: 'control-flow-migration', + description: + 'Migrates from `*ngIf`, `*ngFor`, and `*ngSwitch` to the new `@if`, `@for`, and `@switch` block syntax in templates.', + documentationUrl: 'https://angular.dev/reference/migrations/control-flow', + }, + { + name: 'self-closing-tags-migration', + description: + 'Converts tags for elements with no content to be self-closing (e.g., `` becomes ``).', + documentationUrl: 'https://angular.dev/reference/migrations/self-closing-tags', + }, + { + name: 'test-bed-get', + description: + 'Updates `TestBed.get` to the preferred and type-safe `TestBed.inject` in TypeScript test files.', + documentationUrl: 'https://angular.dev/guide/testing/dependency-injection', + }, + { + name: 'inject-flags', + description: + 'Updates `inject` calls from using the InjectFlags enum to a more modern and readable options object.', + documentationUrl: 'https://angular.dev/reference/migrations/inject-function', + }, + { + name: 'output-migration', + description: 'Converts `@Output` declarations to the new functional `output()` syntax.', + documentationUrl: 'https://angular.dev/reference/migrations/outputs', + }, + { + name: 'signal-input-migration', + description: 'Migrates `@Input` declarations to the new signal-based `input()` syntax.', + documentationUrl: 'https://angular.dev/reference/migrations/signal-inputs', + }, + { + name: 'signal-queries-migration', + description: + 'Migrates `@ViewChild` and `@ContentChild` queries to their signal-based `viewChild` and `contentChild` versions.', + documentationUrl: 'https://angular.dev/reference/migrations/signal-queries', + }, + { + name: 'standalone', + description: + 'Converts the application to use standalone components, directives, and pipes. This is a ' + + 'three-step process. After each step, you should verify that your application builds and ' + + 'runs correctly.', + instructions: + 'This migration requires running a cli schematic multiple times. Run the commands in the ' + + 'order listed below, verifying that your code builds and runs between each step:\n\n' + + '1. Run `ng g @angular/core:standalone` and select "Convert all components, directives and pipes to standalone"\n' + + '2. Run `ng g @angular/core:standalone` and select "Remove unnecessary NgModule classes"\n' + + '3. Run `ng g @angular/core:standalone` and select "Bootstrap the project using standalone APIs"', + documentationUrl: 'https://angular.dev/reference/migrations/standalone', + }, + { + name: 'zoneless', + description: 'Migrates the application to be zoneless.', + documentationUrl: 'https://angular.dev/guide/zoneless', + }, +]; + +const modernizeInputSchema = z.object({ + // Casting to [string, ...string[]] since the enum definition requires a nonempty array. + transformations: z + .array(z.enum(TRANSFORMATIONS.map((t) => t.name) as [string, ...string[]])) + .optional(), +}); + +export type ModernizeInput = z.infer; + +function generateInstructions(transformationNames: string[]): string[] { + if (transformationNames.length === 0) { + return [ + 'See https://angular.dev/best-practices for Angular best practices. ' + + 'You can call this tool if you have specific transformation you want to run.', + ]; + } + + const instructions: string[] = []; + const transformationsToRun = TRANSFORMATIONS.filter((t) => transformationNames?.includes(t.name)); + + for (const transformation of transformationsToRun) { + let transformationInstructions = ''; + if (transformation.instructions) { + transformationInstructions = transformation.instructions; + } else { + // If no instructions are included, default to running a cli schematic with the transformation name. + const command = `ng generate @angular/core:${transformation.name}`; + transformationInstructions = `To run the ${transformation.name} migration, execute the following command: \`${command}\`.`; + } + if (transformation.documentationUrl) { + transformationInstructions += `\nFor more information, see ${transformation.documentationUrl}.`; + } + instructions.push(transformationInstructions); + } + + return instructions; +} + +export async function runModernization(input: ModernizeInput) { + const structuredContent = { instructions: generateInstructions(input.transformations ?? []) }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }], + structuredContent, + }; +} + +export function registerModernizeTool(server: McpServer): void { + server.registerTool( + 'modernize', + { + title: 'Modernize Angular Code', + description: + '\n' + + 'This tool modernizes Angular code by applying the latest best practices and syntax improvements, ' + + 'ensuring it is idiomatic, readable, and maintainable.\n\n' + + '\n' + + '\n' + + '* After generating new code: Run this tool immediately after creating new Angular components, directives, ' + + 'or services to ensure they adhere to modern standards.\n' + + '* On existing code: Apply to existing TypeScript files (.ts) and Angular templates (.ng.html) to update ' + + 'them with the latest features, such as the new built-in control flow syntax.\n\n' + + '* When the user asks for a specific transformation: When the transformation list is populated, ' + + 'these specific ones will be ran on the inputs.\n' + + '\n' + + '\n' + + TRANSFORMATIONS.map((t) => `* ${t.name}: ${t.description}`).join('\n') + + '\n\n', + annotations: { + readOnlyHint: true, + }, + inputSchema: modernizeInputSchema.shape, + outputSchema: { + instructions: z + .array(z.string()) + .optional() + .describe('A list of instructions on how to run the migrations.'), + }, + }, + (input) => runModernization(input), + ); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts new file mode 100644 index 000000000000..cc49dcba10b6 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts @@ -0,0 +1,73 @@ +/** + * @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 { ModernizeInput, runModernization } from './modernize'; + +describe('Modernize Tool', () => { + async function getInstructions(input: ModernizeInput): Promise { + const { structuredContent } = await runModernization(input); + + if (!structuredContent || !('instructions' in structuredContent)) { + fail('Expected instructions to be present in the result'); + + return; + } + + return structuredContent.instructions; + } + + it('should return an instruction for a single transformation', async () => { + const instructions = await getInstructions({ + transformations: ['self-closing-tags-migration'], + }); + + expect(instructions).toEqual([ + 'To run the self-closing-tags-migration migration, execute the following command: ' + + '`ng generate @angular/core:self-closing-tags-migration`.\nFor more information, ' + + 'see https://angular.dev/reference/migrations/self-closing-tags.', + ]); + }); + + it('should return instructions for multiple transformations', async () => { + const instructions = await getInstructions({ + transformations: ['self-closing-tags-migration', 'test-bed-get'], + }); + + const expectedInstructions = [ + 'To run the self-closing-tags-migration migration, execute the following command: ' + + '`ng generate @angular/core:self-closing-tags-migration`.\nFor more information, ' + + 'see https://angular.dev/reference/migrations/self-closing-tags.', + 'To run the test-bed-get migration, execute the following command: ' + + '`ng generate @angular/core:test-bed-get`.\nFor more information, ' + + 'see https://angular.dev/guide/testing/dependency-injection.', + ]; + + expect(instructions?.sort()).toEqual(expectedInstructions.sort()); + }); + + it('should return a link to the best practices page when no transformations are requested', async () => { + const instructions = await getInstructions({ + transformations: [], + }); + + expect(instructions).toEqual([ + 'See https://angular.dev/best-practices for Angular best practices. You can call this ' + + 'tool if you have specific transformation you want to run.', + ]); + }); + + it('should return special instructions for standalone migration', async () => { + const instructions = await getInstructions({ + transformations: ['standalone'], + }); + + expect(instructions?.[0]).toContain( + 'Run the commands in the order listed below, verifying that your code builds and runs between each step:', + ); + }); +});