11import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
2- import { SchematicTestRunner , UnitTestTree } from '@angular-devkit/schematics/testing' ;
3- import { Tree } from '@angular-devkit/schematics' ;
42import { z } from 'zod' ;
53import 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' ;
69
7- export const SCHEMATICS_ROOT = '../../../../../../node_modules/ @angular/core/schematics' ;
10+ export const SCHEMATICS_ROOT = '../../../../../../@angular/core/schematics' ;
811
912enum SchematicTarget {
1013 Code ,
@@ -96,6 +99,165 @@ const TRANSFORMATIONS = [
9699] as const ;
97100
98101const ALL_TRANSFORMATIONS = TRANSFORMATIONS . map ( ( t ) => t . name ) ;
102+
103+ const modernizeInputSchema = z . object ( {
104+ files : z . array (
105+ z . object ( {
106+ name : z . string ( ) . describe ( 'The name of the file.' ) ,
107+ content : z . string ( ) . describe ( 'The content of the file.' ) ,
108+ } ) ,
109+ ) ,
110+ transformations : z . array ( z . enum ( ALL_TRANSFORMATIONS as [ string , ...string [ ] ] ) ) . optional ( ) ,
111+ mode : z
112+ . enum ( [ 'convert-to-standalone' , 'prune-ng-modules' , 'standalone-bootstrap' ] )
113+ . optional ( )
114+ . describe ( 'The mode to use for the standalone transformation.' ) ,
115+ } ) ;
116+
117+ export type ModernizeInput = z . infer < typeof modernizeInputSchema > ;
118+
119+ // Extracted logic for testability
120+ export async function runModernization (
121+ input : ModernizeInput ,
122+ workflow ?: NodeWorkflow ,
123+ tempDir ?: string ,
124+ ) {
125+ const ownTempDir = ! tempDir ;
126+ tempDir ??= fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'angular-cli-modernize-' ) ) ;
127+ try {
128+ const fileNames = input . files . map ( ( f ) => f . name ) ;
129+ const tsconfig = {
130+ compilerOptions : {
131+ target : 'es2022' ,
132+ module : 'esnext' ,
133+ lib : [ 'es2022' , 'dom' ] ,
134+ skipLibCheck : true ,
135+ esModuleInterop : true ,
136+ allowSyntheticDefaultImports : true ,
137+ experimentalDecorators : true ,
138+ emitDecoratorMetadata : true ,
139+ useDefineForClassFields : false ,
140+ } ,
141+ files : fileNames ,
142+ } ;
143+ fs . writeFileSync ( path . join ( tempDir , 'tsconfig.json' ) , JSON . stringify ( tsconfig , null , 2 ) ) ;
144+ for ( const file of input . files ) {
145+ const filePath = path . join ( tempDir , file . name ) ;
146+ fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } ) ;
147+ fs . writeFileSync ( filePath , file . content ) ;
148+ }
149+
150+ workflow ??= new NodeWorkflow (
151+ new virtualFs . ScopedHost ( new NodeJsSyncHost ( ) , path . normalize ( tempDir ) as any ) ,
152+ {
153+ packageManager : 'pnpm' ,
154+ dryRun : false ,
155+ } ,
156+ ) ;
157+
158+ const collectionPaths = {
159+ [ SchematicRunner . Migration ] : path . join ( __dirname , SCHEMATICS_ROOT , 'migrations.json' ) ,
160+ [ SchematicRunner . Collection ] : path . join ( __dirname , SCHEMATICS_ROOT , 'collection.json' ) ,
161+ } ;
162+
163+ const transformationsToRun =
164+ input . transformations && input . transformations . length > 0
165+ ? TRANSFORMATIONS . filter ( ( t ) => input . transformations ! . includes ( t . name ) )
166+ : TRANSFORMATIONS . filter ( ( t ) => t . includedByDefault ) ;
167+
168+ const hasCodeFiles = input . files . some ( ( f ) => f . name . endsWith ( '.ts' ) ) ;
169+ const hasTemplateFiles = input . files . some (
170+ ( f ) => f . name . endsWith ( '.html' ) || f . name . endsWith ( '.ng.html' ) ,
171+ ) ;
172+
173+ let instructions : string | undefined ;
174+ const documentation = new Set < string > ( ) ;
175+
176+ for ( const transformation of transformationsToRun ) {
177+ if ( transformation . documentation ) {
178+ documentation . add ( transformation . documentation ) ;
179+ }
180+
181+ let options : { [ key : string ] : unknown } = { path : tempDir } ;
182+ if ( transformation . name === 'standalone' ) {
183+ const mode = input . mode ?? 'convert-to-standalone' ;
184+ options = { ...options , mode } ;
185+
186+ if ( mode === 'convert-to-standalone' ) {
187+ instructions =
188+ '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.' ;
189+ } else if ( mode === 'prune-ng-modules' ) {
190+ instructions =
191+ '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.' ;
192+ } else {
193+ instructions = 'The `standalone` migration has been completed.' ;
194+ }
195+ } else if ( transformation . name === 'zoneless' ) {
196+ instructions =
197+ 'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.' ;
198+ continue ; // Don't run a schematic for zoneless
199+ }
200+
201+ if (
202+ ( transformation . target === SchematicTarget . Code && ! hasCodeFiles ) ||
203+ ( transformation . target === SchematicTarget . Template && ! hasTemplateFiles )
204+ ) {
205+ continue ;
206+ }
207+
208+ await workflow
209+ . execute ( {
210+ collection : collectionPaths [ transformation . runner ] ,
211+ schematic : transformation . name ,
212+ options,
213+ } )
214+ . toPromise ( ) ;
215+ }
216+
217+ const updatedFiles = input . files . map ( ( file ) => {
218+ const filePath = path . join ( tempDir ! , file . name ) ;
219+ if ( ! fs . existsSync ( filePath ) ) {
220+ return { name : file . name , content : undefined , changed : true } ;
221+ }
222+ const updatedContent = fs . readFileSync ( filePath , 'utf8' ) ;
223+ const changed = updatedContent !== file . content ;
224+
225+ return {
226+ name : file . name ,
227+ content : changed ? updatedContent : undefined ,
228+ changed,
229+ } ;
230+ } ) ;
231+
232+ const structuredContent = {
233+ files : updatedFiles ,
234+ instructions,
235+ documentation : documentation . size > 0 ? [ ...documentation ] . join ( '\n' ) : undefined ,
236+ } ;
237+
238+ return {
239+ content : [ { type : 'text' as const , text : JSON . stringify ( structuredContent ) } ] ,
240+ structuredContent,
241+ } ;
242+ } catch ( e ) {
243+ const message = e instanceof Error ? e . message : 'An unknown error occurred.' ;
244+ return {
245+ content : [
246+ {
247+ type : 'text' as const ,
248+ text : `Failed to run modernization migrations: ${ message } ` ,
249+ } ,
250+ ] ,
251+ structuredContent : { } ,
252+ isError : true ,
253+ } ;
254+ } finally {
255+ if ( ownTempDir && tempDir ) {
256+ fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
257+ }
258+ }
259+ }
260+
99261export function registerModernizeTool ( server : McpServer ) : void {
100262 server . registerTool (
101263 'modernize' ,
@@ -122,134 +284,24 @@ export function registerModernizeTool(server: McpServer): void {
122284 . join ( '\n' ) +
123285 '/\n<On-Request Transformations>\n' +
124286 '\n</Transformations>\n' ,
125- inputSchema : {
126- files : z . array (
127- z . object ( {
128- name : z . string ( ) . describe ( 'The name of the file.' ) ,
129- content : z . string ( ) . describe ( 'The content of the file.' ) ,
130- } ) ,
131- ) ,
132- // Zod's `enum` requires a non-empty array of string literals, but TypeScript
133- // infers `ALL_TRANSFORMATIONS` as `string[]`. The `as` cast is a
134- // workaround to satisfy Zod's type checker.
135- transformations : z
136- . array ( z . enum ( ALL_TRANSFORMATIONS as [ string , ...string [ ] ] ) )
137- . optional ( ) ,
138- mode : z
139- . enum ( [ 'convert-to-standalone' , 'prune-ng-modules' , 'standalone-bootstrap' ] )
140- . optional ( )
141- . describe ( 'The mode to use for the standalone transformation.' ) ,
287+ annotations : {
288+ readOnlyHint : true ,
142289 } ,
290+ inputSchema : modernizeInputSchema . shape ,
143291 outputSchema : {
144- files : z . array (
145- z . object ( {
146- name : z . string ( ) . describe ( 'The name of the file.' ) ,
147- content : z . string ( ) . optional ( ) . describe ( 'The updated content of the file.' ) ,
148- changed : z . boolean ( ) . describe ( 'Whether the file was changed.' ) ,
149- } ) ,
150- ) ,
292+ files : z
293+ . array (
294+ z . object ( {
295+ name : z . string ( ) . describe ( 'The name of the file.' ) ,
296+ content : z . string ( ) . optional ( ) . describe ( 'The updated content of the file.' ) ,
297+ changed : z . boolean ( ) . describe ( 'Whether the file was changed.' ) ,
298+ } ) ,
299+ )
300+ . optional ( ) ,
151301 instructions : z . string ( ) . optional ( ) . describe ( 'Additional instructions.' ) ,
152302 documentation : z . string ( ) . optional ( ) . describe ( 'A link to relevant documentation.' ) ,
153303 } ,
154-
155- } ,
156- async ( input ) => {
157- try {
158- // We don't have access to the file system to run schematics on it directly. Instead we
159- // use the test runner to create a virtual filesystem, populate it with the input files,
160- // and run the schematics on them.
161-
162- const runners = {
163- [ SchematicRunner . Migration ] : new SchematicTestRunner (
164- '@angular/core' ,
165- path . join ( __dirname , SCHEMATICS_ROOT , 'migrations.json' ) ,
166- ) ,
167- [ SchematicRunner . Collection ] : new SchematicTestRunner (
168- '@angular/core' ,
169- path . join ( __dirname , SCHEMATICS_ROOT , 'collection.json' ) ,
170- ) ,
171- } ;
172-
173- const transformationsToRun =
174- input . transformations && input . transformations . length > 0
175- ? TRANSFORMATIONS . filter ( ( t ) => input . transformations ! . includes ( t . name ) )
176- : TRANSFORMATIONS . filter ( ( t ) => t . includedByDefault ) ;
177-
178- const hasCodeFiles = input . files . some ( ( f ) => f . name . endsWith ( '.ts' ) ) ;
179- const hasTemplateFiles = input . files . some (
180- ( f ) => f . name . endsWith ( '.html' ) || f . name . endsWith ( '.ng.html' ) ,
181- ) ;
182-
183- let tree : Tree = new UnitTestTree ( Tree . empty ( ) ) ;
184- for ( const file of input . files ) {
185- tree . create ( file . name , file . content ) ;
186- }
187-
188- let instructions : string | undefined ;
189- const documentation = new Set < string > ( ) ;
190-
191- for ( const transformation of transformationsToRun ) {
192- if ( transformation . documentation ) {
193- documentation . add ( transformation . documentation ) ;
194- }
195- if ( transformation . name === 'standalone' ) {
196- const mode = input . mode ?? 'convert-to-standalone' ;
197- tree = await runners [ transformation . runner ] . runSchematic ( 'standalone' , { mode } , tree ) ;
198-
199- if ( mode === 'convert-to-standalone' ) {
200- instructions =
201- '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.' ;
202- } else if ( mode === 'prune-ng-modules' ) {
203- instructions =
204- '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.' ;
205- } else {
206- instructions = 'The `standalone` migration has been completed.' ;
207- }
208- } else if ( transformation . name === 'zoneless' ) {
209- instructions =
210- 'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.' ;
211- } else if ( transformation . target === SchematicTarget . Code && hasCodeFiles ) {
212- tree = await runners [ transformation . runner ] . runSchematic ( transformation . name , { } , tree ) ;
213- } else if ( transformation . target === SchematicTarget . Template && hasTemplateFiles ) {
214- tree = await runners [ transformation . runner ] . runSchematic ( transformation . name , { } , tree ) ;
215- }
216- }
217-
218- const updatedFiles = input . files . map ( ( file ) => {
219- const updatedContent = tree . read ( file . name ) ?. toString ( ) ;
220- const changed = updatedContent !== undefined && updatedContent !== file . content ;
221-
222- return {
223- name : file . name ,
224- content : changed ? updatedContent : undefined ,
225- changed,
226- } ;
227- } ) ;
228-
229- return {
230- content : [
231- {
232- type : 'text' as const ,
233- text : 'Modernization migrations applied successfully.' ,
234- } ,
235- ] ,
236- structuredContent : {
237- files : updatedFiles ,
238- instructions,
239- documentation : documentation . size > 0 ? [ ...documentation ] . join ( '\n' ) : undefined ,
240- } ,
241- } ;
242- } catch ( e ) {
243- const message = e instanceof Error ? e . message : 'An unknown error occurred.' ;
244- return {
245- content : [
246- {
247- type : 'text' as const ,
248- text : `Failed to run modernization migrations: ${ message } ` ,
249- } ,
250- ] ,
251- } ;
252- }
253304 } ,
305+ ( input ) => runModernization ( input as ModernizeInput ) ,
254306 ) ;
255- }
307+ }
0 commit comments