Skip to content

Commit ab4b5b5

Browse files
committed
feat(@angular/cli): Add a "build" MCP tool
1 parent ce3fa2c commit ab4b5b5

File tree

13 files changed

+949
-17
lines changed

13 files changed

+949
-17
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
if (this.port) {
101+
args.push(`--port=${this.port}`);
102+
}
103+
104+
this.devServerProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
105+
this.devServerProcess.stdout?.on('data', (data) => {
106+
this.addLog(data.toString());
107+
});
108+
this.devServerProcess.stderr?.on('data', (data) => {
109+
this.addLog(data.toString());
110+
});
111+
this.devServerProcess.stderr?.on('close', () => {
112+
this.stop();
113+
});
114+
this.buildInProgress = true;
115+
}
116+
117+
private addLog(log: string) {
118+
this.serverLogs.push(log);
119+
120+
if (BUILD_START_MESSAGES.some((message) => log.startsWith(message))) {
121+
this.buildInProgress = true;
122+
this.latestBuildLogStartIndex = this.serverLogs.length - 1;
123+
} else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) {
124+
this.buildInProgress = false;
125+
// We consider everything except a specific failure message to be a success.
126+
this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'failure' : 'success';
127+
}
128+
}
129+
130+
stop() {
131+
this.devServerProcess?.kill();
132+
this.devServerProcess = null;
133+
}
134+
135+
getServerLogs(): string[] {
136+
return [...this.serverLogs];
137+
}
138+
139+
getMostRecentBuild() {
140+
return {
141+
status: this.latestBuildStatus,
142+
logs: this.serverLogs.slice(this.latestBuildLogStartIndex),
143+
};
144+
}
145+
146+
isBuilding() {
147+
return this.buildInProgress;
148+
}
149+
}

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
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.
@@ -68,14 +69,38 @@ export interface Host {
6869
env?: Record<string, string>;
6970
},
7071
): Promise<{ stdout: string; stderr: string }>;
72+
73+
/**
74+
* Spawns a long-running child process and returns the `ChildProcess` object.
75+
* @param command The command to run.
76+
* @param args The arguments to pass to the command.
77+
* @param options Options for the child process.
78+
* @returns The spawned `ChildProcess` instance.
79+
*/
80+
spawn(
81+
command: string,
82+
args: readonly string[],
83+
options?: {
84+
stdio?: 'pipe' | 'ignore';
85+
cwd?: string;
86+
env?: Record<string, string>;
87+
},
88+
): ChildProcess;
89+
90+
/**
91+
* Finds an available TCP port on the system.
92+
*/
93+
getAvailablePort(): Promise<number>;
7194
}
7295

7396
/**
7497
* A concrete implementation of the `Host` interface that runs on a local workspace.
7598
*/
7699
export const LocalWorkspaceHost: Host = {
77100
stat,
101+
78102
existsSync: nodeExistsSync,
103+
79104
runCommand: async (
80105
command: string,
81106
args: readonly string[],
@@ -127,4 +152,50 @@ export const LocalWorkspaceHost: Host = {
127152
});
128153
});
129154
},
155+
156+
spawn(
157+
command: string,
158+
args: readonly string[],
159+
options: {
160+
stdio?: 'pipe' | 'ignore';
161+
cwd?: string;
162+
env?: Record<string, string>;
163+
} = {},
164+
): ChildProcess {
165+
return spawn(command, args, {
166+
shell: false,
167+
stdio: options.stdio ?? 'pipe',
168+
cwd: options.cwd,
169+
env: {
170+
...process.env,
171+
...options.env,
172+
},
173+
});
174+
},
175+
176+
getAvailablePort(): Promise<number> {
177+
return new Promise((resolve, reject) => {
178+
// Create a new temporary server from Node's net library.
179+
const server = createServer();
180+
181+
server.once('error', (err: unknown) => {
182+
reject(err);
183+
});
184+
185+
// Listen on port 0 to let the OS assign an available port.
186+
server.listen(0, () => {
187+
const address = server.address();
188+
189+
// Ensure address is an object with a port property.
190+
if (address && typeof address === 'object') {
191+
const port = address.port;
192+
193+
server.close();
194+
resolve(port);
195+
} else {
196+
reject(new Error('Unable to retrieve address information from server.'));
197+
}
198+
});
199+
});
200+
},
130201
};

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,25 @@ 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';
1618
import { DOC_SEARCH_TOOL } from './tools/doc-search';
1719
import { FIND_EXAMPLE_TOOL } from './tools/examples';
1820
import { MODERNIZE_TOOL } from './tools/modernize';
1921
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
2022
import { LIST_PROJECTS_TOOL } from './tools/projects';
23+
import { START_DEVSERVER_TOOL } from './tools/start-devserver';
24+
import { STOP_DEVSERVER_TOOL } from './tools/stop-devserver';
2125
import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
26+
import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/wait-for-devserver-build';
27+
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];
2232

2333
/**
2434
* The set of tools that are enabled by default for the MCP server.
@@ -36,7 +46,12 @@ const STABLE_TOOLS = [
3646
* The set of tools that are available but not enabled by default.
3747
* These tools are considered experimental and may have limitations.
3848
*/
39-
export const EXPERIMENTAL_TOOLS = [MODERNIZE_TOOL, ZONELESS_MIGRATION_TOOL] as const;
49+
export const EXPERIMENTAL_TOOLS = [
50+
BUILD_TOOL,
51+
MODERNIZE_TOOL,
52+
ZONELESS_MIGRATION_TOOL,
53+
...SERVE_TOOLS,
54+
] as const;
4055

4156
export async function createMcpServer(
4257
options: {
@@ -103,6 +118,7 @@ equivalent actions.
103118
workspace: options.workspace,
104119
logger,
105120
exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'),
121+
devServers: new Map<string, DevServer>(),
106122
},
107123
toolDeclarations,
108124
);

0 commit comments

Comments
 (0)