diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 6cbb09b36c31..e173d31e5413 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -38,6 +38,7 @@ ts_project( ], exclude = [ "**/*_spec.ts", + "**/testing/**", ], ) + [ # These files are generated from the JSON schema @@ -116,7 +117,10 @@ ts_project( name = "angular-cli_test_lib", testonly = True, srcs = glob( - include = ["**/*_spec.ts"], + include = [ + "**/*_spec.ts", + "**/testing/**", + ], exclude = [ # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces "node_modules/**", diff --git a/packages/angular/cli/src/commands/mcp/dev-server.ts b/packages/angular/cli/src/commands/mcp/dev-server.ts new file mode 100644 index 000000000000..7645e6010abb --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/dev-server.ts @@ -0,0 +1,148 @@ +/** + * @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 { ChildProcess } from 'child_process'; +import { Host } from './host'; + +// Log messages that we want to catch to identify the build status. + +const BUILD_SUCCEEDED_MESSAGE = 'Application bundle generation complete.'; +const BUILD_FAILED_MESSAGE = 'Application bundle generation failed.'; +const WAITING_FOR_CHANGES_MESSAGE = 'Watch mode enabled. Watching for file changes...'; +const CHANGES_DETECTED_START_MESSAGE = '❯ Changes detected. Rebuilding...'; +const CHANGES_DETECTED_SUCCESS_MESSAGE = '✔ Changes detected. Rebuilding...'; + +const BUILD_START_MESSAGES = [CHANGES_DETECTED_START_MESSAGE]; +const BUILD_END_MESSAGES = [ + BUILD_SUCCEEDED_MESSAGE, + BUILD_FAILED_MESSAGE, + WAITING_FOR_CHANGES_MESSAGE, + CHANGES_DETECTED_SUCCESS_MESSAGE, +]; + +export type BuildStatus = 'success' | 'failure' | 'unknown'; + +/** + * An Angular development server managed by the MCP server. + */ +export interface DevServer { + /** + * Launches the dev server and returns immediately. + * + * Throws if this server is already running. + */ + start(): void; + + /** + * If the dev server is running, stops it. + */ + stop(): void; + + /** + * Gets all the server logs so far (stdout + stderr). + */ + getServerLogs(): string[]; + + /** + * Gets all the server logs from the latest build. + */ + getMostRecentBuild(): { status: BuildStatus; logs: string[] }; + + /** + * Whether the dev server is currently being built, or is awaiting further changes. + */ + isBuilding(): boolean; + + /** + * `ng serve` port to use. + */ + port: number; +} + +export function devServerKey(project?: string) { + return project ?? ''; +} + +/** + * A local Angular development server managed by the MCP server. + */ +export class LocalDevServer implements DevServer { + readonly host: Host; + readonly port: number; + readonly project?: string; + + private devServerProcess: ChildProcess | null = null; + private serverLogs: string[] = []; + private buildInProgress = false; + private latestBuildLogStartIndex?: number = undefined; + private latestBuildStatus: BuildStatus = 'unknown'; + + constructor({ host, port, project }: { host: Host; port: number; project?: string }) { + this.host = host; + this.project = project; + this.port = port; + } + + start() { + if (this.devServerProcess) { + throw Error('Dev server already started.'); + } + + const args = ['serve']; + if (this.project) { + args.push(this.project); + } + + args.push(`--port=${this.port}`); + + this.devServerProcess = this.host.spawn('ng', args, { stdio: 'pipe' }); + this.devServerProcess.stdout?.on('data', (data) => { + this.addLog(data.toString()); + }); + this.devServerProcess.stderr?.on('data', (data) => { + this.addLog(data.toString()); + }); + this.devServerProcess.stderr?.on('close', () => { + this.stop(); + }); + this.buildInProgress = true; + } + + private addLog(log: string) { + this.serverLogs.push(log); + + if (BUILD_START_MESSAGES.some((message) => log.startsWith(message))) { + this.buildInProgress = true; + this.latestBuildLogStartIndex = this.serverLogs.length - 1; + } else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) { + this.buildInProgress = false; + // We consider everything except a specific failure message to be a success. + this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'failure' : 'success'; + } + } + + stop() { + this.devServerProcess?.kill(); + this.devServerProcess = null; + } + + getServerLogs(): string[] { + return [...this.serverLogs]; + } + + getMostRecentBuild() { + return { + status: this.latestBuildStatus, + logs: this.serverLogs.slice(this.latestBuildLogStartIndex), + }; + } + + isBuilding() { + return this.buildInProgress; + } +} diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index ad57b03550bf..0be6ff67a7fc 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -14,9 +14,10 @@ */ import { existsSync as nodeExistsSync } from 'fs'; -import { spawn } from 'node:child_process'; +import { ChildProcess, spawn } from 'node:child_process'; import { Stats } from 'node:fs'; import { stat } from 'node:fs/promises'; +import { createServer } from 'node:net'; /** * An error thrown when a command fails to execute. @@ -24,8 +25,7 @@ import { stat } from 'node:fs/promises'; export class CommandError extends Error { constructor( message: string, - public readonly stdout: string, - public readonly stderr: string, + public readonly logs: string[], public readonly code: number | null, ) { super(message); @@ -67,7 +67,29 @@ export interface Host { cwd?: string; env?: Record; }, - ): Promise<{ stdout: string; stderr: string }>; + ): Promise<{ logs: string[] }>; + + /** + * Spawns a long-running child process and returns the `ChildProcess` object. + * @param command The command to run. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns The spawned `ChildProcess` instance. + */ + spawn( + command: string, + args: readonly string[], + options?: { + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + }, + ): ChildProcess; + + /** + * Finds an available TCP port on the system. + */ + getAvailablePort(): Promise; } /** @@ -75,7 +97,9 @@ export interface Host { */ export const LocalWorkspaceHost: Host = { stat, + existsSync: nodeExistsSync, + runCommand: async ( command: string, args: readonly string[], @@ -85,7 +109,7 @@ export const LocalWorkspaceHost: Host = { cwd?: string; env?: Record; } = {}, - ): Promise<{ stdout: string; stderr: string }> => { + ): Promise<{ logs: string[] }> => { const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; return new Promise((resolve, reject) => { @@ -100,30 +124,74 @@ export const LocalWorkspaceHost: Host = { }, }); - let stdout = ''; - childProcess.stdout?.on('data', (data) => (stdout += data.toString())); - - let stderr = ''; - childProcess.stderr?.on('data', (data) => (stderr += data.toString())); + const logs: string[] = []; + childProcess.stdout?.on('data', (data) => logs.push(data.toString())); + childProcess.stderr?.on('data', (data) => logs.push(data.toString())); childProcess.on('close', (code) => { if (code === 0) { - resolve({ stdout, stderr }); + resolve({ logs }); } else { const message = `Process exited with code ${code}.`; - reject(new CommandError(message, stdout, stderr, code)); + reject(new CommandError(message, logs, code)); } }); childProcess.on('error', (err) => { if (err.name === 'AbortError') { const message = `Process timed out.`; - reject(new CommandError(message, stdout, stderr, null)); + reject(new CommandError(message, logs, null)); return; } const message = `Process failed with error: ${err.message}`; - reject(new CommandError(message, stdout, stderr, null)); + reject(new CommandError(message, logs, null)); + }); + }); + }, + + spawn( + command: string, + args: readonly string[], + options: { + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + } = {}, + ): ChildProcess { + return spawn(command, args, { + shell: false, + stdio: options.stdio ?? 'pipe', + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + }); + }, + + getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + // Create a new temporary server from Node's net library. + const server = createServer(); + + server.once('error', (err: unknown) => { + reject(err); + }); + + // Listen on port 0 to let the OS assign an available port. + server.listen(0, () => { + const address = server.address(); + + // Ensure address is an object with a port property. + if (address && typeof address === 'object') { + const port = address.port; + + server.close(); + resolve(port); + } else { + reject(new Error('Unable to retrieve address information from server.')); + } }); }); }, diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 8a006c98be60..e4c2c799f4b5 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -10,9 +10,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import path from 'node:path'; import type { AngularWorkspace } from '../../utilities/config'; import { VERSION } from '../../utilities/version'; +import { DevServer } from './dev-server'; import { registerInstructionsResource } from './resources/instructions'; import { AI_TUTOR_TOOL } from './tools/ai-tutor'; import { BEST_PRACTICES_TOOL } from './tools/best-practices'; +import { BUILD_TOOL } from './tools/build'; +import { START_DEVSERVER_TOOL } from './tools/devserver/start-devserver'; +import { STOP_DEVSERVER_TOOL } from './tools/devserver/stop-devserver'; +import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/devserver/wait-for-devserver-build'; import { DOC_SEARCH_TOOL } from './tools/doc-search'; import { FIND_EXAMPLE_TOOL } from './tools/examples'; import { MODERNIZE_TOOL } from './tools/modernize'; @@ -20,6 +25,11 @@ import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zonel import { LIST_PROJECTS_TOOL } from './tools/projects'; import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry'; +/** + * Tools to manage devservers. Should be bundled together, then added to experimental or stable as a group. + */ +const SERVE_TOOLS = [START_DEVSERVER_TOOL, STOP_DEVSERVER_TOOL, WAIT_FOR_DEVSERVER_BUILD_TOOL]; + /** * The set of tools that are enabled by default for the MCP server. * These tools are considered stable and suitable for general use. @@ -37,7 +47,7 @@ const STABLE_TOOLS = [ * The set of tools that are available but not enabled by default. * These tools are considered experimental and may have limitations. */ -export const EXPERIMENTAL_TOOLS = [MODERNIZE_TOOL] as const; +export const EXPERIMENTAL_TOOLS = [BUILD_TOOL, MODERNIZE_TOOL, ...SERVE_TOOLS] as const; export async function createMcpServer( options: { @@ -104,6 +114,7 @@ equivalent actions. workspace: options.workspace, logger, exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'), + devServers: new Map(), }, toolDeclarations, ); diff --git a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts new file mode 100644 index 000000000000..0a758a6925f5 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts @@ -0,0 +1,21 @@ +/** + * @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 { Host } from '../host'; + +/** + * A mock implementation of the `Host` interface for testing purposes. + * This class allows spying on host methods and controlling their return values. + */ +export class MockHost implements Host { + runCommand = jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' }); + stat = jasmine.createSpy('stat'); + existsSync = jasmine.createSpy('existsSync'); + spawn = jasmine.createSpy('spawn'); + getAvailablePort = jasmine.createSpy('getAvailablePort'); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/build.ts b/packages/angular/cli/src/commands/mcp/tools/build.ts new file mode 100644 index 000000000000..75812460bd22 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/build.ts @@ -0,0 +1,111 @@ +/** + * @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 { z } from 'zod'; +import { CommandError, Host, LocalWorkspaceHost } from '../host'; +import { createStructuredContentOutput } from '../utils'; +import { McpToolDeclaration, declareTool } from './tool-registry'; + +const DEFAULT_CONFIGURATION = 'development'; + +const buildStatusSchema = z.enum(['success', 'failure']); +type BuildStatus = z.infer; + +const buildToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe( + 'Which project to build in a monorepo context. If not provided, builds the default project.', + ), + configuration: z + .string() + .optional() + .describe('Which build configuration to use. Defaults to "development".'), +}); + +export type BuildToolInput = z.infer; + +const buildToolOutputSchema = z.object({ + status: buildStatusSchema.describe('Build status.'), + logs: z.array(z.string()).optional().describe('Output logs from `ng build`.'), + path: z.string().optional().describe('The output location for the build, if successful.'), +}); + +export type BuildToolOutput = z.infer; + +export async function runBuild(input: BuildToolInput, host: Host) { + // Build "ng"'s command line. + const args = ['build']; + if (input.project) { + args.push(input.project); + } + args.push('-c', input.configuration ?? DEFAULT_CONFIGURATION); + + let status: BuildStatus = 'success'; + let logs: string[] = []; + let outputPath: string | undefined; + + try { + logs = (await host.runCommand('ng', args)).logs; + } catch (e) { + status = 'failure'; + if (e instanceof CommandError) { + logs = e.logs; + } else if (e instanceof Error) { + logs = [e.message]; + } else { + logs = [String(e)]; + } + } + + for (const line of logs) { + const match = line.match(/Output location: (.*)/); + if (match) { + outputPath = match[1].trim(); + break; + } + } + + const structuredContent: BuildToolOutput = { + status, + logs, + path: outputPath, + }; + + return createStructuredContentOutput(structuredContent); +} + +export const BUILD_TOOL: McpToolDeclaration< + typeof buildToolInputSchema.shape, + typeof buildToolOutputSchema.shape +> = declareTool({ + name: 'build', + title: 'Build Tool', + description: ` + +Perform a one-off, non-watched build using "ng build". Use this tool whenever the user wants to build an Angular project; this is similar to +"ng build", but the tool is smarter about using the right configuration and collecting the output logs. + + +* Building an Angular project and getting build logs back. + + +* This tool runs "ng build" so it expects to run within an Angular workspace. +* If you want a watched build which updates as files are changed, use "start_devserver" instead, which also serves the app. +* You can provide a project instead of building the root one. The "list_projects" MCP tool could be used to obtain the list of projects. +* This tool defaults to a development environment while a regular "ng build" defaults to a production environment. An unexpected build + failure might suggest the project is not configured for the requested environment. + +`, + isReadOnly: false, + isLocalOnly: true, + inputSchema: buildToolInputSchema.shape, + outputSchema: buildToolOutputSchema.shape, + factory: () => (input) => runBuild(input, LocalWorkspaceHost), +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts new file mode 100644 index 000000000000..20678501b977 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts @@ -0,0 +1,99 @@ +/** + * @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 { CommandError, Host } from '../host'; +import { MockHost } from '../testing/mock-host'; +import { runBuild } from './build'; + +describe('Build Tool', () => { + let mockHost: MockHost; + + beforeEach(() => { + mockHost = { + runCommand: jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }), + stat: jasmine.createSpy('stat'), + existsSync: jasmine.createSpy('existsSync'), + } as Partial as MockHost; + }); + + it('should construct the command correctly with default configuration', async () => { + await runBuild({}, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'development']); + }); + + it('should construct the command correctly with a specified project', async () => { + await runBuild({ project: 'another-app' }, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'build', + 'another-app', + '-c', + 'development', + ]); + }); + + it('should construct the command correctly for a custom configuration', async () => { + await runBuild({ configuration: 'myconfig' }, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'myconfig']); + }); + + it('should handle a successful build and extract the output path and logs', async () => { + const buildLogs = [ + 'Build successful!', + 'Some other log lines...', + 'some warning', + 'Output location: dist/my-app', + ]; + mockHost.runCommand.and.resolveTo({ + logs: buildLogs, + }); + + const { structuredContent } = await runBuild({ project: 'my-app' }, mockHost); + + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'build', + 'my-app', + '-c', + 'development', + ]); + expect(structuredContent.status).toBe('success'); + expect(structuredContent.logs).toEqual(buildLogs); + expect(structuredContent.path).toBe('dist/my-app'); + }); + + it('should handle a failed build and capture logs', async () => { + const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!']; + const error = new CommandError('Build failed', buildLogs, 1); + mockHost.runCommand.and.rejectWith(error); + + const { structuredContent } = await runBuild( + { project: 'my-failed-app', configuration: 'production' }, + mockHost, + ); + + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'build', + 'my-failed-app', + '-c', + 'production', + ]); + expect(structuredContent.status).toBe('failure'); + expect(structuredContent.logs).toEqual(buildLogs); + expect(structuredContent.path).toBeUndefined(); + }); + + it('should handle builds where the output path is not found in logs', async () => { + const buildLogs = ["Some logs that don't match any output path."]; + mockHost.runCommand.and.resolveTo({ logs: buildLogs }); + + const { structuredContent } = await runBuild({}, mockHost); + + expect(structuredContent.status).toBe('success'); + expect(structuredContent.logs).toEqual(buildLogs); + expect(structuredContent.path).toBeUndefined(); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/serve_spec.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/serve_spec.ts new file mode 100644 index 000000000000..5b3116125223 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/serve_spec.ts @@ -0,0 +1,172 @@ +/** + * @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 { EventEmitter } from 'events'; +import { ChildProcess } from 'node:child_process'; +import { MockHost } from '../../testing/mock-host'; +import { McpToolContext } from '../tool-registry'; +import { startDevServer } from './start-devserver'; +import { stopDevserver } from './stop-devserver'; +import { WATCH_DELAY, waitForDevserverBuild } from './wait-for-devserver-build'; + +class MockChildProcess extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = jasmine.createSpy('kill'); +} + +describe('Serve Tools', () => { + let mockHost: MockHost; + let mockContext: McpToolContext; + let mockProcess: MockChildProcess; + let portCounter: number; + + beforeEach(() => { + portCounter = 12345; + mockProcess = new MockChildProcess(); + mockHost = { + spawn: jasmine.createSpy('spawn').and.returnValue(mockProcess as unknown as ChildProcess), + getAvailablePort: jasmine.createSpy('getAvailablePort').and.callFake(() => { + return Promise.resolve(portCounter++); + }), + } as Partial as MockHost; + + mockContext = { + devServers: new Map(), + } as Partial as McpToolContext; + }); + + it('should start and stop a dev server', async () => { + const startResult = await startDevServer({}, mockContext, mockHost); + expect(startResult.structuredContent.message).toBe( + `Development server for project '' started and watching for workspace changes.`, + ); + expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', '--port=12345'], { stdio: 'pipe' }); + + const stopResult = stopDevserver({}, mockContext); + expect(stopResult.structuredContent.message).toBe( + `Development server for project '' stopped.`, + ); + expect(mockProcess.kill).toHaveBeenCalled(); + }); + + it('should wait for a build to complete', async () => { + await startDevServer({}, mockContext, mockHost); + + const waitPromise = waitForDevserverBuild({ timeout: 10 }, mockContext); + + // Simulate build logs. + mockProcess.stdout.emit('data', '... building ...'); + mockProcess.stdout.emit('data', '✔ Changes detected. Rebuilding...'); + mockProcess.stdout.emit('data', '... more logs ...'); + mockProcess.stdout.emit('data', 'Application bundle generation complete.'); + + const waitResult = await waitPromise; + expect(waitResult.structuredContent.status).toBe('success'); + expect(waitResult.structuredContent.logs).toEqual([ + '... building ...', + '✔ Changes detected. Rebuilding...', + '... more logs ...', + 'Application bundle generation complete.', + ]); + }); + + it('should handle multiple dev servers', async () => { + // Start server for project 1. This uses the basic mockProcess created for the tests. + const startResult1 = await startDevServer({ project: 'app-one' }, mockContext, mockHost); + expect(startResult1.structuredContent.message).toBe( + `Development server for project 'app-one' started and watching for workspace changes.`, + ); + const process1 = mockProcess; + + // Start server for project 2, returning a new mock process. + const process2 = new MockChildProcess(); + mockHost.spawn.and.returnValue(process2 as unknown as ChildProcess); + const startResult2 = await startDevServer({ project: 'app-two' }, mockContext, mockHost); + expect(startResult2.structuredContent.message).toBe( + `Development server for project 'app-two' started and watching for workspace changes.`, + ); + + expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], { + stdio: 'pipe', + }); + expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-two', '--port=12346'], { + stdio: 'pipe', + }); + + // Stop server for project 1 + const stopResult1 = stopDevserver({ project: 'app-one' }, mockContext); + expect(stopResult1.structuredContent.message).toBe( + `Development server for project 'app-one' stopped.`, + ); + expect(process1.kill).toHaveBeenCalled(); + expect(process2.kill).not.toHaveBeenCalled(); + + // Stop server for project 2 + const stopResult2 = stopDevserver({ project: 'app-two' }, mockContext); + expect(stopResult2.structuredContent.message).toBe( + `Development server for project 'app-two' stopped.`, + ); + expect(process2.kill).toHaveBeenCalled(); + }); + + it('should handle server crash', async () => { + await startDevServer({ project: 'crash-app' }, mockContext, mockHost); + + // Simulate a crash with exit code 1 + mockProcess.stdout.emit('data', 'Fatal error.'); + mockProcess.emit('close', 1); + + const stopResult = stopDevserver({ project: 'crash-app' }, mockContext); + expect(stopResult.structuredContent.message).toContain('stopped'); + expect(stopResult.structuredContent.logs).toEqual(['Fatal error.']); + }); + + it('wait should timeout if build takes too long', async () => { + await startDevServer({ project: 'timeout-app' }, mockContext, mockHost); + const waitResult = await waitForDevserverBuild( + { project: 'timeout-app', timeout: 10 }, + mockContext, + ); + expect(waitResult.structuredContent.status).toBe('timeout'); + }); + + it('should wait through multiple cycles for a build to complete', async () => { + jasmine.clock().install(); + try { + await startDevServer({}, mockContext, mockHost); + + // Immediately simulate a build starting so isBuilding() is true. + mockProcess.stdout.emit('data', '❯ Changes detected. Rebuilding...'); + + const waitPromise = waitForDevserverBuild({ timeout: 5 * WATCH_DELAY }, mockContext); + + // Tick past the first debounce. The while loop will be entered. + jasmine.clock().tick(WATCH_DELAY + 1); + + // Tick past the second debounce (inside the loop). + jasmine.clock().tick(WATCH_DELAY + 1); + + // Now finish the build. + mockProcess.stdout.emit('data', 'Application bundle generation complete.'); + + // Tick past another debounce to exit the loop. + jasmine.clock().tick(WATCH_DELAY + 1); + + const waitResult = await waitPromise; + + expect(waitResult.structuredContent.status).toBe('success'); + expect(waitResult.structuredContent.logs).toEqual([ + '❯ Changes detected. Rebuilding...', + 'Application bundle generation complete.', + ]); + } finally { + jasmine.clock().uninstall(); + } + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/start-devserver.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/start-devserver.ts new file mode 100644 index 000000000000..abc6a8cdfa33 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/start-devserver.ts @@ -0,0 +1,103 @@ +/** + * @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 { z } from 'zod'; +import { LocalDevServer, devServerKey } from '../../dev-server'; +import { Host, LocalWorkspaceHost } from '../../host'; +import { createStructuredContentOutput } from '../../utils'; +import { McpToolContext, McpToolDeclaration, declareTool } from '../tool-registry'; + +const startDevServerToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe( + 'Which project to serve in a monorepo context. If not provided, serves the default project.', + ), +}); + +export type StartDevserverToolInput = z.infer; + +const startDevServerToolOutputSchema = z.object({ + message: z.string().describe('A message indicating the result of the operation.'), + address: z + .string() + .optional() + .describe( + 'If the operation was successful, this is the HTTP address that the server can be found at.', + ), +}); + +export type StartDevserverToolOutput = z.infer; + +function localhostAddress(port: number) { + return `http://localhost:${port}/`; +} + +export async function startDevServer( + input: StartDevserverToolInput, + context: McpToolContext, + host: Host, +) { + const projectKey = devServerKey(input.project); + + let devServer = context.devServers.get(projectKey); + if (devServer) { + return createStructuredContentOutput({ + message: `Development server for project '${projectKey}' is already running.`, + address: localhostAddress(devServer.port), + }); + } + + const port = await host.getAvailablePort(); + + devServer = new LocalDevServer({ host, project: input.project, port }); + devServer.start(); + + context.devServers.set(projectKey, devServer); + + return createStructuredContentOutput({ + message: `Development server for project '${projectKey}' started and watching for workspace changes.`, + address: localhostAddress(port), + }); +} + +export const START_DEVSERVER_TOOL: McpToolDeclaration< + typeof startDevServerToolInputSchema.shape, + typeof startDevServerToolOutputSchema.shape +> = declareTool({ + name: 'start_devserver', + title: 'Start Development Server', + description: ` + +Starts the Angular development server ("ng serve") as a background process. Follow this up with "wait_for_devserver_build" to wait until +the first build completes. + + +* **Starting the Server:** Use this tool to begin serving the application. The tool will return immediately while the server runs in the + background. +* **Get Initial Build Logs:** Once a dev server has started, use the "wait_for_devserver_build" tool to ensure it's alive. If there are any + build errors, "wait_for_devserver_build" would provide them back and you can give them to the user or rely on them to propose a fix. +* **Get Updated Build Logs:** Important: as long as a devserver is alive (i.e. "stop_devserver" wasn't called), after every time you make a + change to the workspace, re-run "wait_for_devserver_build" to see whether the change was successfully built and wait for the devserver to + be updated. + + +* This tool manages development servers by itself. It maintains at most a single dev server instance for each project in the monorepo. +* This is an asynchronous operation. Subsequent commands can be ran while the server is active. +* Use 'stop_devserver' to gracefully shut down the server and access the full log output. + +`, + isReadOnly: true, + isLocalOnly: true, + inputSchema: startDevServerToolInputSchema.shape, + outputSchema: startDevServerToolOutputSchema.shape, + factory: (context) => (input) => { + return startDevServer(input, context, LocalWorkspaceHost); + }, +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/stop-devserver.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/stop-devserver.ts new file mode 100644 index 000000000000..842910e6cac0 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/stop-devserver.ts @@ -0,0 +1,78 @@ +/** + * @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 { z } from 'zod'; +import { devServerKey } from '../../dev-server'; +import { createStructuredContentOutput } from '../../utils'; +import { McpToolContext, McpToolDeclaration, declareTool } from '../tool-registry'; + +const stopDevserverToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe( + 'Which project to stop serving in a monorepo context. If not provided, stops the default project server.', + ), +}); + +export type StopDevserverToolInput = z.infer; + +const stopDevserverToolOutputSchema = z.object({ + message: z.string().describe('A message indicating the result of the operation.'), + logs: z.array(z.string()).optional().describe('The full logs from the dev server.'), +}); + +export type StopDevserverToolOutput = z.infer; + +export function stopDevserver(input: StopDevserverToolInput, context: McpToolContext) { + const projectKey = devServerKey(input.project); + const devServer = context.devServers.get(projectKey); + + if (!devServer) { + return createStructuredContentOutput({ + message: `Development server for project '${projectKey}' was not running.`, + logs: undefined, + }); + } + + devServer.stop(); + context.devServers.delete(projectKey); + + return createStructuredContentOutput({ + message: `Development server for project '${projectKey}' stopped.`, + logs: devServer.getServerLogs(), + }); +} + +export const STOP_DEVSERVER_TOOL: McpToolDeclaration< + typeof stopDevserverToolInputSchema.shape, + typeof stopDevserverToolOutputSchema.shape +> = declareTool({ + name: 'stop_devserver', + title: 'Stop Development Server', + description: ` + +Stops a running Angular development server ("ng serve") that was started with the "start_devserver" tool. + + +* **Stopping the Server:** Use this tool to terminate a running development server and retrieve the logs. + + +* This should be called to gracefully shut down the server and access the full log output. +* This just sends a SIGTERM to the server and returns immediately; so the server might still be functional for a short + time after this is called. However note that this is not a blocker for starting a new devserver. + +`, + isReadOnly: true, + isLocalOnly: true, + inputSchema: stopDevserverToolInputSchema.shape, + outputSchema: stopDevserverToolOutputSchema.shape, + factory: (context) => (input) => { + return stopDevserver(input, context); + }, +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/wait-for-devserver-build.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/wait-for-devserver-build.ts new file mode 100644 index 000000000000..0d698c8f452a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/wait-for-devserver-build.ts @@ -0,0 +1,122 @@ +/** + * @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 { z } from 'zod'; +import { devServerKey } from '../../dev-server'; +import { createStructuredContentOutput } from '../../utils'; +import { McpToolContext, McpToolDeclaration, declareTool } from '../tool-registry'; + +/** + * How long to wait to give "ng serve" time to identify whether the watched workspace has changed. + */ +export const WATCH_DELAY = 1000; + +/** + * Default timeout for waiting for the build to complete. + */ +const DEFAULT_TIMEOUT = 180_000; // In milliseconds + +const waitForDevserverBuildToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe( + 'Which project to wait for in a monorepo context. If not provided, waits for the default project server.', + ), + timeout: z + .number() + .default(DEFAULT_TIMEOUT) + .describe( + `The maximum time to wait for the build to complete, in milliseconds. This can't be lower than ${WATCH_DELAY}.`, + ), +}); + +export type WaitForDevserverBuildToolInput = z.infer; + +const waitForDevserverBuildToolOutputSchema = z.object({ + status: z + .enum(['success', 'failure', 'unknown', 'timeout', 'no_devserver_found']) + .describe( + "The status of the build if it's complete, or a status indicating why the wait operation failed.", + ), + logs: z + .array(z.string()) + .optional() + .describe('The logs from the most recent build, if one exists.'), +}); + +export type WaitForDevserverBuildToolOutput = z.infer; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForDevserverBuild( + input: WaitForDevserverBuildToolInput, + context: McpToolContext, +) { + const projectKey = devServerKey(input.project); + const devServer = context.devServers.get(projectKey); + const deadline = Date.now() + input.timeout; + + if (!devServer) { + return createStructuredContentOutput({ + status: 'no_devserver_found', + }); + } + + await wait(WATCH_DELAY); + while (devServer.isBuilding()) { + if (Date.now() > deadline) { + return createStructuredContentOutput({ + status: 'timeout', + }); + } + await wait(WATCH_DELAY); + } + + return createStructuredContentOutput({ + ...devServer.getMostRecentBuild(), + }); +} + +export const WAIT_FOR_DEVSERVER_BUILD_TOOL: McpToolDeclaration< + typeof waitForDevserverBuildToolInputSchema.shape, + typeof waitForDevserverBuildToolOutputSchema.shape +> = declareTool({ + name: 'wait_for_devserver_build', + title: 'Wait for Devserver Build', + description: ` + +Waits for a dev server that was started with the "start_devserver" tool to complete its build, then reports the build logs from its most +recent build. + + +* **Waiting for a build:** As long as a devserver is alive ("start_devserver" was called for this project and "stop_devserver" wasn't + called yet), then if you're making a file change and want to ensure it was successfully built, call this tool instead of any other build + tool or command. When it retuns you'll get build logs back **and** you'll know the user's devserver is up-to-date with the latest changes. + + +* This tool expects that a dev server was launched on the same project with the "start_devserver" tool, otherwise a "no_devserver_found" + status will be returned. +* This tool will block until the build is complete or the timeout is reached. If you expect a long build process, consider increasing the + timeout. Timeouts on initial run (right after "start_devserver" calls) or after a big change are not necessarily indicative of an error. +* If you encountered a timeout and it might be reasonable, just call this tool again. +* If the dev server is not building, it will return quickly, with the logs from the last build. +* A 'no_devserver_found' status can indicate the underlying server was stopped for some reason. Try first to call the "start_devserver" + tool again, before giving up. + +`, + isReadOnly: true, + isLocalOnly: true, + inputSchema: waitForDevserverBuildToolInputSchema.shape, + outputSchema: waitForDevserverBuildToolOutputSchema.shape, + factory: (context) => (input) => { + return waitForDevserverBuild(input, context); + }, +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize.ts b/packages/angular/cli/src/commands/mcp/tools/modernize.ts index 2ad3e737578c..fc9b1d6cc45a 100644 --- a/packages/angular/cli/src/commands/mcp/tools/modernize.ts +++ b/packages/angular/cli/src/commands/mcp/tools/modernize.ts @@ -9,6 +9,7 @@ import { dirname, join, relative } from 'path'; import { z } from 'zod'; import { CommandError, Host, LocalWorkspaceHost } from '../host'; +import { createStructuredContentOutput } from '../utils'; import { McpToolDeclaration, declareTool } from './tool-registry'; interface Transformation { @@ -86,20 +87,12 @@ const modernizeOutputSchema = z.object({ .describe( 'Migration summary, as well as any instructions that need to be performed to complete the migrations.', ), - stdout: z.string().optional().describe('The stdout from the executed commands.'), - stderr: z.string().optional().describe('The stderr from the executed commands.'), + logs: z.array(z.string()).optional().describe('All logs from all executed commands.'), }); export type ModernizeInput = z.infer; export type ModernizeOutput = z.infer; -function createToolOutput(structuredContent: ModernizeOutput) { - return { - content: [{ type: 'text' as const, text: JSON.stringify(structuredContent, null, 2) }], - structuredContent, - }; -} - function findAngularJsonDir(startDir: string, host: Host): string | null { let currentDir = startDir; while (true) { @@ -119,7 +112,7 @@ export async function runModernization(input: ModernizeInput, host: Host) { const directories = input.directories ?? []; if (transformationNames.length === 0) { - return createToolOutput({ + return createStructuredContentOutput({ 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.', @@ -127,7 +120,7 @@ export async function runModernization(input: ModernizeInput, host: Host) { }); } if (directories.length === 0) { - return createToolOutput({ + return createStructuredContentOutput({ instructions: [ 'Provide this tool with a list of directory paths in your workspace ' + 'to run the modernization on.', @@ -140,14 +133,13 @@ export async function runModernization(input: ModernizeInput, host: Host) { const angularProjectRoot = findAngularJsonDir(executionDir, host); if (!angularProjectRoot) { - return createToolOutput({ + return createStructuredContentOutput({ instructions: ['Could not find an angular.json file in the current or parent directories.'], }); } const instructions: string[] = []; - const stdoutMessages: string[] = []; - const stderrMessages: string[] = []; + let logs: string[] = []; const transformationsToRun = TRANSFORMATIONS.filter((t) => transformationNames.includes(t.name)); for (const transformation of transformationsToRun) { @@ -165,28 +157,19 @@ export async function runModernization(input: ModernizeInput, host: Host) { const command = 'ng'; const args = ['generate', `@angular/core:${transformation.name}`, '--path', relativePath]; try { - const { stdout, stderr } = await host.runCommand(command, args, { - cwd: angularProjectRoot, - }); - if (stdout) { - stdoutMessages.push(stdout); - } - if (stderr) { - stderrMessages.push(stderr); - } + logs = ( + await host.runCommand(command, args, { + cwd: angularProjectRoot, + }) + ).logs; instructions.push( `Migration ${transformation.name} on directory ${relativePath} completed successfully.`, ); } catch (e) { if (e instanceof CommandError) { - if (e.stdout) { - stdoutMessages.push(e.stdout); - } - if (e.stderr) { - stderrMessages.push(e.stderr); - } + logs = e.logs; } - stderrMessages.push((e as Error).message); + logs.push((e as Error).message); instructions.push( `Migration ${transformation.name} on directory ${relativePath} failed.`, ); @@ -195,10 +178,9 @@ export async function runModernization(input: ModernizeInput, host: Host) { } } - return createToolOutput({ + return createStructuredContentOutput({ instructions: instructions.length > 0 ? instructions : undefined, - stdout: stdoutMessages?.join('\n\n') || undefined, - stderr: stderrMessages?.join('\n\n') || undefined, + logs, }); } diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts index a00894dde5f6..13b0e55f6946 100644 --- a/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts @@ -10,12 +10,13 @@ import { Stats } from 'fs'; import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -import * as host from '../host'; +import { CommandError } from '../host'; +import { MockHost } from '../testing/mock-host'; import { ModernizeOutput, runModernization } from './modernize'; describe('Modernize Tool', () => { let projectDir: string; - let mockHost: host.Host; + let mockHost: MockHost; beforeEach(async () => { // Create a temporary directory and a fake angular.json to satisfy the tool's project root search. @@ -28,7 +29,7 @@ describe('Modernize Tool', () => { existsSync: jasmine.createSpy('existsSync').and.callFake((p: string) => { return p === join(projectDir, 'angular.json'); }), - }; + } as Partial as MockHost; }); afterEach(async () => { @@ -105,7 +106,7 @@ describe('Modernize Tool', () => { ['generate', '@angular/core:self-closing-tag', '--path', '.'], { cwd: projectDir }, ); - expect(structuredContent?.stderr).toBeUndefined(); + expect(structuredContent?.logs).toBeUndefined(); expect(structuredContent?.instructions).toEqual( jasmine.arrayWithExactContents([ 'Migration control-flow on directory . completed successfully.', @@ -149,7 +150,7 @@ describe('Modernize Tool', () => { ['generate', '@angular/core:self-closing-tag', '--path', 'subfolder2'], { cwd: projectDir }, ); - expect(structuredContent?.stderr).toBeUndefined(); + expect(structuredContent?.logs).toBeUndefined(); expect(structuredContent?.instructions).toEqual( jasmine.arrayWithExactContents([ 'Migration control-flow on directory subfolder1 completed successfully.', @@ -161,7 +162,7 @@ describe('Modernize Tool', () => { }); it('should return an error if angular.json is not found', async () => { - (mockHost.existsSync as jasmine.Spy).and.returnValue(false); + mockHost.existsSync.and.returnValue(false); const { structuredContent } = (await runModernization( { @@ -179,8 +180,8 @@ describe('Modernize Tool', () => { it('should report errors from transformations', async () => { // Simulate a failed execution - (mockHost.runCommand as jasmine.Spy).and.rejectWith( - new host.CommandError('Command failed with error', 'stdout', 'stderr', 1), + mockHost.runCommand.and.rejectWith( + new CommandError('Command failed with error', ['some logs'], 1), ); const { structuredContent } = (await runModernization( @@ -196,9 +197,7 @@ describe('Modernize Tool', () => { ['generate', '@angular/core:self-closing-tag', '--path', '.'], { cwd: projectDir }, ); - expect(structuredContent?.stdout).toContain('stdout'); - expect(structuredContent?.stderr).toContain('stderr'); - expect(structuredContent?.stderr).toContain('Command failed with error'); + expect(structuredContent?.logs).toEqual(['some logs', 'Command failed with error']); expect(structuredContent?.instructions).toEqual([ 'Migration self-closing-tag on directory . failed.', ]); diff --git a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts index 4a4ee474428f..a70d4185dd81 100644 --- a/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts +++ b/packages/angular/cli/src/commands/mcp/tools/tool-registry.ts @@ -9,6 +9,7 @@ import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ZodRawShape } from 'zod'; import type { AngularWorkspace } from '../../../utilities/config'; +import { DevServer } from '../dev-server'; type ToolConfig = Parameters[1]; @@ -17,6 +18,7 @@ export interface McpToolContext { workspace?: AngularWorkspace; logger: { warn(text: string): void }; exampleDatabasePath?: string; + devServers: Map; } export type McpToolFactory = ( diff --git a/packages/angular/cli/src/commands/mcp/utils.ts b/packages/angular/cli/src/commands/mcp/utils.ts new file mode 100644 index 000000000000..49fa697ceca8 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/utils.ts @@ -0,0 +1,24 @@ +/** + * @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 + */ + +/** + * @fileoverview + * Utility functions shared across MCP tools. + */ + +/** + * Returns simple structured content output from an MCP tool. + * + * @returns A structure with both `content` and `structuredContent` for maximum compatibility. + */ +export function createStructuredContentOutput(structuredContent: OutputType) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(structuredContent, null, 2) }], + structuredContent, + }; +}