Skip to content

Commit c3c9ac5

Browse files
amishnealan-agius4
authored andcommitted
feat(@angular/cli): Add MCP tools for building and running devservers
New tools: * build * start_devserver * stop_devserver * wait_for_devserver_build See tool descriptions and MCP documentations for more.
1 parent d556c60 commit c3c9ac5

File tree

15 files changed

+1004
-60
lines changed

15 files changed

+1004
-60
lines changed

packages/angular/cli/BUILD.bazel

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ ts_project(
3838
],
3939
exclude = [
4040
"**/*_spec.ts",
41+
"**/testing/**",
4142
],
4243
) + [
4344
# These files are generated from the JSON schema
@@ -116,7 +117,10 @@ ts_project(
116117
name = "angular-cli_test_lib",
117118
testonly = True,
118119
srcs = glob(
119-
include = ["**/*_spec.ts"],
120+
include = [
121+
"**/*_spec.ts",
122+
"**/testing/**",
123+
],
120124
exclude = [
121125
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces
122126
"node_modules/**",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { ChildProcess } from 'child_process';
10+
import { Host } from './host';
11+
12+
// Log messages that we want to catch to identify the build status.
13+
14+
const BUILD_SUCCEEDED_MESSAGE = 'Application bundle generation complete.';
15+
const BUILD_FAILED_MESSAGE = 'Application bundle generation failed.';
16+
const WAITING_FOR_CHANGES_MESSAGE = 'Watch mode enabled. Watching for file changes...';
17+
const CHANGES_DETECTED_START_MESSAGE = '❯ Changes detected. Rebuilding...';
18+
const CHANGES_DETECTED_SUCCESS_MESSAGE = '✔ Changes detected. Rebuilding...';
19+
20+
const BUILD_START_MESSAGES = [CHANGES_DETECTED_START_MESSAGE];
21+
const BUILD_END_MESSAGES = [
22+
BUILD_SUCCEEDED_MESSAGE,
23+
BUILD_FAILED_MESSAGE,
24+
WAITING_FOR_CHANGES_MESSAGE,
25+
CHANGES_DETECTED_SUCCESS_MESSAGE,
26+
];
27+
28+
export type BuildStatus = 'success' | 'failure' | 'unknown';
29+
30+
/**
31+
* An Angular development server managed by the MCP server.
32+
*/
33+
export interface DevServer {
34+
/**
35+
* Launches the dev server and returns immediately.
36+
*
37+
* Throws if this server is already running.
38+
*/
39+
start(): void;
40+
41+
/**
42+
* If the dev server is running, stops it.
43+
*/
44+
stop(): void;
45+
46+
/**
47+
* Gets all the server logs so far (stdout + stderr).
48+
*/
49+
getServerLogs(): string[];
50+
51+
/**
52+
* Gets all the server logs from the latest build.
53+
*/
54+
getMostRecentBuild(): { status: BuildStatus; logs: string[] };
55+
56+
/**
57+
* Whether the dev server is currently being built, or is awaiting further changes.
58+
*/
59+
isBuilding(): boolean;
60+
61+
/**
62+
* `ng serve` port to use.
63+
*/
64+
port: number;
65+
}
66+
67+
export function devServerKey(project?: string) {
68+
return project ?? '<default>';
69+
}
70+
71+
/**
72+
* A local Angular development server managed by the MCP server.
73+
*/
74+
export class LocalDevServer implements DevServer {
75+
readonly host: Host;
76+
readonly port: number;
77+
readonly project?: string;
78+
79+
private devServerProcess: ChildProcess | null = null;
80+
private serverLogs: string[] = [];
81+
private buildInProgress = false;
82+
private latestBuildLogStartIndex?: number = undefined;
83+
private latestBuildStatus: BuildStatus = 'unknown';
84+
85+
constructor({ host, port, project }: { host: Host; port: number; project?: string }) {
86+
this.host = host;
87+
this.project = project;
88+
this.port = port;
89+
}
90+
91+
start() {
92+
if (this.devServerProcess) {
93+
throw Error('Dev server already started.');
94+
}
95+
96+
const args = ['serve'];
97+
if (this.project) {
98+
args.push(this.project);
99+
}
100+
101+
args.push(`--port=${this.port}`);
102+
103+
this.devServerProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
104+
this.devServerProcess.stdout?.on('data', (data) => {
105+
this.addLog(data.toString());
106+
});
107+
this.devServerProcess.stderr?.on('data', (data) => {
108+
this.addLog(data.toString());
109+
});
110+
this.devServerProcess.stderr?.on('close', () => {
111+
this.stop();
112+
});
113+
this.buildInProgress = true;
114+
}
115+
116+
private addLog(log: string) {
117+
this.serverLogs.push(log);
118+
119+
if (BUILD_START_MESSAGES.some((message) => log.startsWith(message))) {
120+
this.buildInProgress = true;
121+
this.latestBuildLogStartIndex = this.serverLogs.length - 1;
122+
} else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) {
123+
this.buildInProgress = false;
124+
// We consider everything except a specific failure message to be a success.
125+
this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'failure' : 'success';
126+
}
127+
}
128+
129+
stop() {
130+
this.devServerProcess?.kill();
131+
this.devServerProcess = null;
132+
}
133+
134+
getServerLogs(): string[] {
135+
return [...this.serverLogs];
136+
}
137+
138+
getMostRecentBuild() {
139+
return {
140+
status: this.latestBuildStatus,
141+
logs: this.serverLogs.slice(this.latestBuildLogStartIndex),
142+
};
143+
}
144+
145+
isBuilding() {
146+
return this.buildInProgress;
147+
}
148+
}

packages/angular/cli/src/commands/mcp/host.ts

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@
1414
*/
1515

1616
import { existsSync as nodeExistsSync } from 'fs';
17-
import { spawn } from 'node:child_process';
17+
import { ChildProcess, spawn } from 'node:child_process';
1818
import { Stats } from 'node:fs';
1919
import { stat } from 'node:fs/promises';
20+
import { createServer } from 'node:net';
2021

2122
/**
2223
* An error thrown when a command fails to execute.
2324
*/
2425
export class CommandError extends Error {
2526
constructor(
2627
message: string,
27-
public readonly stdout: string,
28-
public readonly stderr: string,
28+
public readonly logs: string[],
2929
public readonly code: number | null,
3030
) {
3131
super(message);
@@ -67,15 +67,39 @@ export interface Host {
6767
cwd?: string;
6868
env?: Record<string, string>;
6969
},
70-
): Promise<{ stdout: string; stderr: string }>;
70+
): Promise<{ logs: string[] }>;
71+
72+
/**
73+
* Spawns a long-running child process and returns the `ChildProcess` object.
74+
* @param command The command to run.
75+
* @param args The arguments to pass to the command.
76+
* @param options Options for the child process.
77+
* @returns The spawned `ChildProcess` instance.
78+
*/
79+
spawn(
80+
command: string,
81+
args: readonly string[],
82+
options?: {
83+
stdio?: 'pipe' | 'ignore';
84+
cwd?: string;
85+
env?: Record<string, string>;
86+
},
87+
): ChildProcess;
88+
89+
/**
90+
* Finds an available TCP port on the system.
91+
*/
92+
getAvailablePort(): Promise<number>;
7193
}
7294

7395
/**
7496
* A concrete implementation of the `Host` interface that runs on a local workspace.
7597
*/
7698
export const LocalWorkspaceHost: Host = {
7799
stat,
100+
78101
existsSync: nodeExistsSync,
102+
79103
runCommand: async (
80104
command: string,
81105
args: readonly string[],
@@ -85,7 +109,7 @@ export const LocalWorkspaceHost: Host = {
85109
cwd?: string;
86110
env?: Record<string, string>;
87111
} = {},
88-
): Promise<{ stdout: string; stderr: string }> => {
112+
): Promise<{ logs: string[] }> => {
89113
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
90114

91115
return new Promise((resolve, reject) => {
@@ -100,30 +124,74 @@ export const LocalWorkspaceHost: Host = {
100124
},
101125
});
102126

103-
let stdout = '';
104-
childProcess.stdout?.on('data', (data) => (stdout += data.toString()));
105-
106-
let stderr = '';
107-
childProcess.stderr?.on('data', (data) => (stderr += data.toString()));
127+
const logs: string[] = [];
128+
childProcess.stdout?.on('data', (data) => logs.push(data.toString()));
129+
childProcess.stderr?.on('data', (data) => logs.push(data.toString()));
108130

109131
childProcess.on('close', (code) => {
110132
if (code === 0) {
111-
resolve({ stdout, stderr });
133+
resolve({ logs });
112134
} else {
113135
const message = `Process exited with code ${code}.`;
114-
reject(new CommandError(message, stdout, stderr, code));
136+
reject(new CommandError(message, logs, code));
115137
}
116138
});
117139

118140
childProcess.on('error', (err) => {
119141
if (err.name === 'AbortError') {
120142
const message = `Process timed out.`;
121-
reject(new CommandError(message, stdout, stderr, null));
143+
reject(new CommandError(message, logs, null));
122144

123145
return;
124146
}
125147
const message = `Process failed with error: ${err.message}`;
126-
reject(new CommandError(message, stdout, stderr, null));
148+
reject(new CommandError(message, logs, null));
149+
});
150+
});
151+
},
152+
153+
spawn(
154+
command: string,
155+
args: readonly string[],
156+
options: {
157+
stdio?: 'pipe' | 'ignore';
158+
cwd?: string;
159+
env?: Record<string, string>;
160+
} = {},
161+
): ChildProcess {
162+
return spawn(command, args, {
163+
shell: false,
164+
stdio: options.stdio ?? 'pipe',
165+
cwd: options.cwd,
166+
env: {
167+
...process.env,
168+
...options.env,
169+
},
170+
});
171+
},
172+
173+
getAvailablePort(): Promise<number> {
174+
return new Promise((resolve, reject) => {
175+
// Create a new temporary server from Node's net library.
176+
const server = createServer();
177+
178+
server.once('error', (err: unknown) => {
179+
reject(err);
180+
});
181+
182+
// Listen on port 0 to let the OS assign an available port.
183+
server.listen(0, () => {
184+
const address = server.address();
185+
186+
// Ensure address is an object with a port property.
187+
if (address && typeof address === 'object') {
188+
const port = address.port;
189+
190+
server.close();
191+
resolve(port);
192+
} else {
193+
reject(new Error('Unable to retrieve address information from server.'));
194+
}
127195
});
128196
});
129197
},

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,26 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1010
import path from 'node:path';
1111
import type { AngularWorkspace } from '../../utilities/config';
1212
import { VERSION } from '../../utilities/version';
13+
import { DevServer } from './dev-server';
1314
import { registerInstructionsResource } from './resources/instructions';
1415
import { AI_TUTOR_TOOL } from './tools/ai-tutor';
1516
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
17+
import { BUILD_TOOL } from './tools/build';
18+
import { START_DEVSERVER_TOOL } from './tools/devserver/start-devserver';
19+
import { STOP_DEVSERVER_TOOL } from './tools/devserver/stop-devserver';
20+
import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/devserver/wait-for-devserver-build';
1621
import { DOC_SEARCH_TOOL } from './tools/doc-search';
1722
import { FIND_EXAMPLE_TOOL } from './tools/examples';
1823
import { MODERNIZE_TOOL } from './tools/modernize';
1924
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
2025
import { LIST_PROJECTS_TOOL } from './tools/projects';
2126
import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2227

28+
/**
29+
* Tools to manage devservers. Should be bundled together, then added to experimental or stable as a group.
30+
*/
31+
const SERVE_TOOLS = [START_DEVSERVER_TOOL, STOP_DEVSERVER_TOOL, WAIT_FOR_DEVSERVER_BUILD_TOOL];
32+
2333
/**
2434
* The set of tools that are enabled by default for the MCP server.
2535
* These tools are considered stable and suitable for general use.
@@ -37,7 +47,7 @@ const STABLE_TOOLS = [
3747
* The set of tools that are available but not enabled by default.
3848
* These tools are considered experimental and may have limitations.
3949
*/
40-
export const EXPERIMENTAL_TOOLS = [MODERNIZE_TOOL] as const;
50+
export const EXPERIMENTAL_TOOLS = [BUILD_TOOL, MODERNIZE_TOOL, ...SERVE_TOOLS] as const;
4151

4252
export async function createMcpServer(
4353
options: {
@@ -104,6 +114,7 @@ equivalent actions.
104114
workspace: options.workspace,
105115
logger,
106116
exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'),
117+
devServers: new Map<string, DevServer>(),
107118
},
108119
toolDeclarations,
109120
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { Host } from '../host';
10+
11+
/**
12+
* A mock implementation of the `Host` interface for testing purposes.
13+
* This class allows spying on host methods and controlling their return values.
14+
*/
15+
export class MockHost implements Host {
16+
runCommand = jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' });
17+
stat = jasmine.createSpy('stat');
18+
existsSync = jasmine.createSpy('existsSync');
19+
spawn = jasmine.createSpy('spawn');
20+
getAvailablePort = jasmine.createSpy('getAvailablePort');
21+
}

0 commit comments

Comments
 (0)