-
Notifications
You must be signed in to change notification settings - Fork 11.9k
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 }] }; | ||
}, | ||
); | ||
} |
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> = [ | ||
{ | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
|
||
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.'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
); | ||
} |
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:', | ||
); | ||
}); | ||
}); |
There was a problem hiding this comment.
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.