Skip to content
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
6 changes: 5 additions & 1 deletion packages/angular/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ ts_project(
],
exclude = [
"**/*_spec.ts",
"**/testing/**",
],
) + [
# These files are generated from the JSON schema
Expand Down Expand Up @@ -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/**",
Expand Down
148 changes: 148 additions & 0 deletions packages/angular/cli/src/commands/mcp/dev-server.ts
Original file line number Diff line number Diff line change
@@ -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...';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: Similar to my point on ng build, would there be value in changing the output of ng serve (possibly behind an environment variable) to make it easier to consume? We probably don't want to tweak the general output, but maybe if we added something like:

MCP MESSAGE: {"status": "started"}
// Some other logs...
MCP MESSAGE: {"status": "finished", "result": "success"}

MCP MESSAGE: {"status": "started"}
// Some other logs...
MCP MESSAGE: {"status": "finished", "result": "error"}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed there as well. I like the environment variable approach, but I prefer to do that in a follow-up


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 ?? '<default>';
}

/**
* 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;
}
}
96 changes: 82 additions & 14 deletions packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@
*/

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.
*/
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);
Expand Down Expand Up @@ -67,15 +67,39 @@ export interface Host {
cwd?: string;
env?: Record<string, string>;
},
): 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<string, string>;
},
): ChildProcess;

/**
* Finds an available TCP port on the system.
*/
getAvailablePort(): Promise<number>;
}

/**
* A concrete implementation of the `Host` interface that runs on a local workspace.
*/
export const LocalWorkspaceHost: Host = {
stat,

existsSync: nodeExistsSync,

runCommand: async (
command: string,
args: readonly string[],
Expand All @@ -85,7 +109,7 @@ export const LocalWorkspaceHost: Host = {
cwd?: string;
env?: Record<string, string>;
} = {},
): Promise<{ stdout: string; stderr: string }> => {
): Promise<{ logs: string[] }> => {
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;

return new Promise((resolve, reject) => {
Expand All @@ -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<string, string>;
} = {},
): ChildProcess {
return spawn(command, args, {
shell: false,
stdio: options.stdio ?? 'pipe',
cwd: options.cwd,
env: {
...process.env,
...options.env,
},
});
},

getAvailablePort(): Promise<number> {
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.'));
}
});
});
},
Expand Down
18 changes: 17 additions & 1 deletion packages/angular/cli/src/commands/mcp/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,26 @@ 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';
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
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.
Expand All @@ -36,7 +46,12 @@ 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, ZONELESS_MIGRATION_TOOL] as const;
export const EXPERIMENTAL_TOOLS = [
BUILD_TOOL,
MODERNIZE_TOOL,
ZONELESS_MIGRATION_TOOL,
...SERVE_TOOLS,
] as const;

export async function createMcpServer(
options: {
Expand Down Expand Up @@ -103,6 +118,7 @@ equivalent actions.
workspace: options.workspace,
logger,
exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'),
devServers: new Map<string, DevServer>(),
},
toolDeclarations,
);
Expand Down
21 changes: 21 additions & 0 deletions packages/angular/cli/src/commands/mcp/testing/mock-host.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Loading