Skip to content

feat(@angular/cli): add modernize tool to the MCP server #30845

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 4 additions & 22 deletions packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions packages/angular/cli/src/commands/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
@@ -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 }] };
},
);
}
192 changes: 192 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/modernize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* @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<Transformation> = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be good to test and experiment. Long term we probably want to try to pull the information from @angular/core directly at runtime. This would avoid needing to keep this information synchronized.

{
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., `<app-foo></app-foo>` becomes `<app-foo />`).',
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<typeof modernizeInputSchema>;

export async function runModernization(input: ModernizeInput) {
try {
if (!input.transformations || input.transformations.length === 0) {
const instructions = [
'See https://angular.dev/best-practices for Angular best practices. ' +
'You can call this tool if you have specific transformation you want to run.',
];

return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
instructions,
}),
},
],
structuredContent: {
instructions,
},
};
Comment on lines +98 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic appears repeated in this function. Maybe consider refactoring this function into something similar to findInstructions that returns an array of instructions. Then handle MCP output structuring in the tool handler?

}

const transformationsToRun = TRANSFORMATIONS.filter((t) =>
input.transformations?.includes(t.name),
);

const allInstructions: string[] = [];

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}.`;
}
allInstructions.push(transformationInstructions);
}

const structuredContent = {
instructions: allInstructions.length ? allInstructions : undefined,
};

return {
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
structuredContent,
};
} catch (e) {
const message = e instanceof Error ? e.message : 'An unknown error occurred.';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK appears to handle this automatically. I don't think the try/catch block is needed here.


return {
content: [
{
type: 'text' as const,
text: `Failed to run modernization migrations: ${message}`,
},
],
structuredContent: {},
isError: true,
};
}
}

export function registerModernizeTool(server: McpServer): void {
server.registerTool(
'modernize',
{
title: 'Modernize Angular Code',
description:
'<Purpose>\n' +
'This tool modernizes Angular code by applying the latest best practices and syntax improvements, ' +
'ensuring it is idiomatic, readable, and maintainable.\n\n' +
'</Purpose>\n' +
'<Use Cases>\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' +
'</Use Cases>\n' +
'<Transformations>\n' +
TRANSFORMATIONS.map((t) => `* ${t.name}: ${t.description}`).join('\n') +
'\n</Transformations>\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),
);
}
73 changes: 73 additions & 0 deletions packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[] | undefined> {
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:',
);
});
});