11import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
22import { z } from 'zod' ;
3- import path from 'node:path' ;
4- import fs from 'node:fs' ;
5- import os from 'node:os' ;
6- import { NodeWorkflow } from '@angular-devkit/schematics/tools' ;
7- import { NodeJsSyncHost } from '@angular-devkit/core/node' ;
8- import { virtualFs } from '@angular-devkit/core' ;
93
10- enum SchematicTarget {
11- Code ,
12- Template ,
4+ interface Transformation {
5+ name : string ;
6+ description : string ;
7+ documentationUrl : string ;
8+ instructions ?: string ;
139}
1410
15- enum SchematicRunner {
16- Migration ,
17- Collection ,
18- }
19-
20- const TRANSFORMATIONS = [
11+ const TRANSFORMATIONS : Array < Transformation > = [
2112 {
2213 name : 'control-flow-migration' ,
2314 description :
2415 'Migrates from `*ngIf`, `*ngFor`, and `*ngSwitch` to the new `@if`, `@for`, and `@switch` block syntax in templates.' ,
25- target : SchematicTarget . Template ,
26- runner : SchematicRunner . Collection ,
27- includedByDefault : true ,
28- documentation : 'https://angular.dev/reference/migrations/control-flow' ,
16+ documentationUrl : 'https://angular.dev/reference/migrations/control-flow' ,
2917 } ,
3018 {
3119 name : 'self-closing-tags-migration' ,
3220 description :
3321 'Converts tags for elements with no content to be self-closing (e.g., `<app-foo></app-foo>` becomes `<app-foo />`).' ,
34- target : SchematicTarget . Template ,
35- runner : SchematicRunner . Collection ,
36- includedByDefault : true ,
37- documentation : 'https://angular.dev/reference/migrations/self-closing-tags' ,
22+ documentationUrl : 'https://angular.dev/reference/migrations/self-closing-tags' ,
3823 } ,
3924 {
4025 name : 'test-bed-get' ,
4126 description :
4227 'Updates `TestBed.get` to the preferred and type-safe `TestBed.inject` in TypeScript test files.' ,
43- target : SchematicTarget . Code ,
44- runner : SchematicRunner . Migration ,
45- includedByDefault : true ,
46- documentation : 'https://angular.dev/guide/testing/dependency-injection' ,
28+ documentationUrl : 'https://angular.dev/guide/testing/dependency-injection' ,
4729 } ,
4830 {
4931 name : 'inject-flags' ,
5032 description :
5133 'Updates `inject` calls from using the InjectFlags enum to a more modern and readable options object.' ,
52- target : SchematicTarget . Code ,
53- runner : SchematicRunner . Migration ,
54- includedByDefault : true ,
55- documentation : 'https://angular.dev/reference/migrations/inject-function' ,
34+ documentationUrl : 'https://angular.dev/reference/migrations/inject-function' ,
5635 } ,
5736 {
5837 name : 'output-migration' ,
5938 description : 'Converts `@Output` declarations to the new functional `output()` syntax.' ,
60- target : SchematicTarget . Code ,
61- runner : SchematicRunner . Collection ,
62- includedByDefault : true ,
63- documentation : 'https://angular.dev/reference/migrations/outputs' ,
39+ documentationUrl : 'https://angular.dev/reference/migrations/outputs' ,
6440 } ,
6541 {
6642 name : 'signal-input-migration' ,
6743 description : 'Migrates `@Input` declarations to the new signal-based `input()` syntax.' ,
68- target : SchematicTarget . Code ,
69- runner : SchematicRunner . Collection ,
70- includedByDefault : true ,
71- documentation : 'https://angular.dev/reference/migrations/signal-inputs' ,
44+ documentationUrl : 'https://angular.dev/reference/migrations/signal-inputs' ,
7245 } ,
7346 {
7447 name : 'signal-queries-migration' ,
7548 description :
7649 'Migrates `@ViewChild` and `@ContentChild` queries to their signal-based `viewChild` and `contentChild` versions.' ,
77- target : SchematicTarget . Code ,
78- runner : SchematicRunner . Collection ,
79- includedByDefault : true ,
80- documentation : 'https://angular.dev/reference/migrations/signal-queries' ,
50+ documentationUrl : 'https://angular.dev/reference/migrations/signal-queries' ,
8151 } ,
8252 {
8353 name : 'standalone' ,
8454 description :
85- '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. Full instructions at https://angular.dev/reference/migrations/standalone' ,
86- target : SchematicTarget . Code ,
87- runner : SchematicRunner . Collection ,
88- includedByDefault : false ,
89- documentation : 'https://angular.dev/reference/migrations/standalone' ,
55+ '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.' ,
56+ 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:
57+
58+ 1. Run \`ng g @angular/core:standalone\` and select "Convert all components, directives and pipes to standalone"
59+ 2. Run \`ng g @angular/core:standalone\` and select "Remove unnecessary NgModule classes"
60+ 3. Run \`ng g @angular/core:standalone\` and select "Bootstrap the project using standalone APIs"
61+ ` ,
62+ documentationUrl : 'https://angular.dev/reference/migrations/standalone' ,
9063 } ,
9164 {
9265 name : 'zoneless' ,
9366 description : 'Migrates the application to be zoneless.' ,
94- includedByDefault : false ,
95- documentation : 'https://angular.dev/guide/zoneless' ,
67+ documentationUrl : 'https://angular.dev/guide/zoneless' ,
9668 } ,
97- ] as const ;
98-
99- const ALL_TRANSFORMATIONS = TRANSFORMATIONS . map ( ( t ) => t . name ) ;
69+ ] ;
10070
10171const modernizeInputSchema = z . object ( {
102- files : z . array (
103- z . object ( {
104- name : z . string ( ) . describe ( 'The name of the file.' ) ,
105- content : z . string ( ) . describe ( 'The content of the file.' ) ,
106- } ) ,
107- ) ,
108- transformations : z . array ( z . enum ( ALL_TRANSFORMATIONS as [ string , ...string [ ] ] ) ) . optional ( ) ,
109- mode : z
110- . enum ( [ 'convert-to-standalone' , 'prune-ng-modules' , 'standalone-bootstrap' ] )
111- . optional ( )
112- . describe ( 'The mode to use for the standalone transformation.' ) ,
72+ // Casting to [string, ...string[]] since the enum definition requires a nonempty array.
73+ transformations : z
74+ . array ( z . enum ( TRANSFORMATIONS . map ( ( t ) => t . name ) as [ string , ...string [ ] ] ) )
75+ . optional ( ) ,
11376} ) ;
11477
11578export type ModernizeInput = z . infer < typeof modernizeInputSchema > ;
11679
117- // Extracted logic for testability
118- export async function runModernization (
119- input : ModernizeInput ,
120- workflow ?: NodeWorkflow ,
121- tempDir ?: string ,
122- ) {
123- const ownTempDir = ! tempDir ;
124- tempDir ??= fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'angular-cli-modernize-' ) ) ;
80+ export async function runModernization ( input : ModernizeInput ) {
12581 try {
126- const fileNames = input . files . map ( ( f ) => f . name ) ;
127- const tsconfig = {
128- compilerOptions : {
129- target : 'es2022' ,
130- module : 'esnext' ,
131- lib : [ 'es2022' , 'dom' ] ,
132- skipLibCheck : true ,
133- esModuleInterop : true ,
134- allowSyntheticDefaultImports : true ,
135- experimentalDecorators : true ,
136- emitDecoratorMetadata : true ,
137- useDefineForClassFields : false ,
138- } ,
139- files : fileNames ,
140- } ;
141- fs . writeFileSync ( path . join ( tempDir , 'tsconfig.json' ) , JSON . stringify ( tsconfig , null , 2 ) ) ;
142- for ( const file of input . files ) {
143- const filePath = path . join ( tempDir , file . name ) ;
144- fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } ) ;
145- fs . writeFileSync ( filePath , file . content ) ;
82+ if ( ! input . transformations || input . transformations . length === 0 ) {
83+ const instructions = [
84+ 'See https://angular.dev/best-practices for Angular best practices. You can call this tool if you have specific transformation you want to run.' ,
85+ ] ;
86+ return {
87+ content : [
88+ {
89+ type : 'text' as const ,
90+ text : JSON . stringify ( {
91+ instructions,
92+ } ) ,
93+ } ,
94+ ] ,
95+ structuredContent : {
96+ instructions,
97+ } ,
98+ } ;
14699 }
147100
148- workflow ??= new NodeWorkflow (
149- new virtualFs . ScopedHost ( new NodeJsSyncHost ( ) , path . normalize ( tempDir ) as any ) ,
150- {
151- packageManager : 'pnpm' ,
152- dryRun : false ,
153- } ,
101+ const transformationsToRun = TRANSFORMATIONS . filter ( ( t ) =>
102+ input . transformations ! . includes ( t . name ) ,
154103 ) ;
155104
156- const angularCorePath = path . dirname ( require . resolve ( '@angular/core/package.json' ) ) ;
157- const collectionPaths = {
158- [ SchematicRunner . Migration ] : path . join ( angularCorePath , 'schematics/migrations.json' ) ,
159- [ SchematicRunner . Collection ] : path . join ( angularCorePath , 'schematics/collection.json' ) ,
160- } ;
161-
162- const transformationsToRun =
163- input . transformations && input . transformations . length > 0
164- ? TRANSFORMATIONS . filter ( ( t ) => input . transformations ! . includes ( t . name ) )
165- : TRANSFORMATIONS . filter ( ( t ) => t . includedByDefault ) ;
166-
167- const hasCodeFiles = input . files . some ( ( f ) => f . name . endsWith ( '.ts' ) ) ;
168- const hasTemplateFiles = input . files . some (
169- ( f ) => f . name . endsWith ( '.html' ) || f . name . endsWith ( '.ng.html' ) ,
170- ) ;
171-
172- let instructions : string | undefined ;
173- const documentation = new Set < string > ( ) ;
105+ const allInstructions : string [ ] = [ ] ;
174106
175107 for ( const transformation of transformationsToRun ) {
176- if ( transformation . documentation ) {
177- documentation . add ( transformation . documentation ) ;
108+ let transformationInstructions = '' ;
109+ if ( transformation . instructions ) {
110+ transformationInstructions = transformation . instructions ;
111+ } else {
112+ // If no instructions are included, default to running a cli schematic with the transformation name.
113+ const command = `ng generate @angular/core:${ transformation . name } ` ;
114+ transformationInstructions = `To run the ${ transformation . name } migration, execute the following command: \`${ command } \`.` ;
178115 }
179-
180- let options : { [ key : string ] : unknown } = { path : '/' } ;
181- if ( transformation . name === 'standalone' ) {
182- const mode = input . mode ?? 'convert-to-standalone' ;
183- options = { ...options , mode } ;
184-
185- if ( mode === 'convert-to-standalone' ) {
186- instructions =
187- 'The first step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "prune-ng-modules"` to continue.' ;
188- } else if ( mode === 'prune-ng-modules' ) {
189- instructions =
190- 'The second step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "standalone-bootstrap"` to complete the migration.' ;
191- } else {
192- instructions = 'The `standalone` migration has been completed.' ;
193- }
194- } else if ( transformation . name === 'zoneless' ) {
195- instructions =
196- 'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.' ;
197- continue ; // Don't run a schematic for zoneless
116+ if ( transformation . documentationUrl ) {
117+ transformationInstructions += `\nFor more information, see ${ transformation . documentationUrl } .` ;
198118 }
199-
200- if (
201- ( transformation . target === SchematicTarget . Code && ! hasCodeFiles ) ||
202- ( transformation . target === SchematicTarget . Template && ! hasTemplateFiles )
203- ) {
204- continue ;
205- }
206-
207- await workflow
208- . execute ( {
209- collection : collectionPaths [ transformation . runner ] ,
210- schematic : transformation . name ,
211- options,
212- } )
213- . toPromise ( ) ;
119+ allInstructions . push ( transformationInstructions ) ;
214120 }
215121
216- const updatedFiles = input . files . map ( ( file ) => {
217- const filePath = path . join ( tempDir ! , file . name ) ;
218- if ( ! fs . existsSync ( filePath ) ) {
219- return { name : file . name , content : undefined , changed : true } ;
220- }
221- const updatedContent = fs . readFileSync ( filePath , 'utf8' ) ;
222- const changed = updatedContent !== file . content ;
223-
224- return {
225- name : file . name ,
226- content : changed ? updatedContent : undefined ,
227- changed,
228- } ;
229- } ) ;
230-
231122 const structuredContent = {
232- files : updatedFiles ,
233- instructions,
234- documentation : documentation . size > 0 ? [ ...documentation ] . join ( '\n' ) : undefined ,
123+ instructions : allInstructions . length ? allInstructions : undefined ,
235124 } ;
236125
237126 return {
@@ -250,10 +139,6 @@ export async function runModernization(
250139 structuredContent : { } ,
251140 isError : true ,
252141 } ;
253- } finally {
254- if ( ownTempDir && tempDir ) {
255- fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
256- }
257142 }
258143}
259144
@@ -272,33 +157,17 @@ export function registerModernizeTool(server: McpServer): void {
272157 '* When the user asks for a specific transformation: When the transformation list is populated, these specific ones will be ran on the inputs.\n' +
273158 '</Use Cases>\n' +
274159 '<Transformations>\n' +
275- '<Default Transformations>\n' +
276- TRANSFORMATIONS . filter ( ( t ) => t . includedByDefault )
277- . map ( ( t ) => `* ${ t . name } : ${ t . description } ` )
278- . join ( '\n' ) +
279- '\n</Default Transformations>\n' +
280- '<On-Request Transformations>\n' +
281- TRANSFORMATIONS . filter ( ( t ) => ! t . includedByDefault )
282- . map ( ( t ) => `* ${ t . name } : ${ t . description } ` )
283- . join ( '\n' ) +
284- '/\n<On-Request Transformations>\n' +
160+ TRANSFORMATIONS . map ( ( t ) => `* ${ t . name } : ${ t . description } ` ) . join ( '\n' ) +
285161 '\n</Transformations>\n' ,
286162 annotations : {
287163 readOnlyHint : true ,
288164 } ,
289165 inputSchema : modernizeInputSchema . shape ,
290166 outputSchema : {
291- files : z
292- . array (
293- z . object ( {
294- name : z . string ( ) . describe ( 'The name of the file.' ) ,
295- content : z . string ( ) . optional ( ) . describe ( 'The updated content of the file.' ) ,
296- changed : z . boolean ( ) . describe ( 'Whether the file was changed.' ) ,
297- } ) ,
298- )
299- . optional ( ) ,
300- instructions : z . string ( ) . optional ( ) . describe ( 'Additional instructions.' ) ,
301- documentation : z . string ( ) . optional ( ) . describe ( 'A link to relevant documentation.' ) ,
167+ instructions : z
168+ . array ( z . string ( ) )
169+ . optional ( )
170+ . describe ( 'A list of instructions on how to run the migrations.' ) ,
302171 } ,
303172 } ,
304173 ( input ) => runModernization ( input as ModernizeInput ) ,
0 commit comments