Skip to content

Commit 71179fc

Browse files
committed
feat(@angular/cli): Working build and serve MCP tools
1 parent 8fc2eed commit 71179fc

File tree

7 files changed

+128
-62
lines changed

7 files changed

+128
-62
lines changed

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import { ChildProcess } from 'child_process';
1010
import { Host } from './host';
1111

12+
// Log messages that we want to catch to identify the build status.
13+
1214
const BUILD_SUCCEEDED_MESSAGE = 'Application bundle generation complete.';
1315
const BUILD_FAILED_MESSAGE = 'Application bundle generation failed.';
1416
const WAITING_FOR_CHANGES_MESSAGE = 'Watch mode enabled. Watching for file changes...';
@@ -55,24 +57,32 @@ export interface DevServer {
5557
* Whether the dev server is currently being built, or is awaiting further changes.
5658
*/
5759
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>';
5869
}
5970

6071
/**
6172
* A local Angular development server managed by the MCP server.
6273
*/
6374
export class LocalDevServer implements DevServer {
6475
readonly host: Host;
65-
readonly port?: number;
76+
readonly port: number;
6677
readonly project?: string;
6778

6879
private devServerProcess: ChildProcess | null = null;
6980
private serverLogs: string[] = [];
7081
private buildInProgress = false;
71-
private latestBuildLogStartIndex = 0;
72-
private latestBuildLogEndIndex = 0;
73-
private latestBuildStatus?: BuildStatus;
82+
private latestBuildLogStartIndex?: number = undefined;
83+
private latestBuildStatus: BuildStatus = 'unknown';
7484

75-
constructor({ host, port, project }: { host: Host; port?: number; project?: string }) {
85+
constructor({ host, port, project }: { host: Host; port: number; project?: string }) {
7686
this.host = host;
7787
this.project = project;
7888
this.port = port;
@@ -93,10 +103,10 @@ export class LocalDevServer implements DevServer {
93103

94104
this.devServerProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
95105
this.devServerProcess.stdout?.on('data', (data) => {
96-
this.serverLogs.push(data.toString());
106+
this.addLog(data.toString());
97107
});
98108
this.devServerProcess.stderr?.on('data', (data) => {
99-
this.serverLogs.push(data.toString());
109+
this.addLog(data.toString());
100110
});
101111
}
102112

@@ -108,8 +118,8 @@ export class LocalDevServer implements DevServer {
108118
this.latestBuildLogStartIndex = this.serverLogs.length;
109119
} else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) {
110120
this.buildInProgress = false;
111-
this.latestBuildLogEndIndex = this.serverLogs.length;
112-
this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'success' : 'failure';
121+
// We consider everything except a specific failure message to be a success.
122+
this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'failure' : 'success';
113123
}
114124
}
115125

@@ -124,8 +134,8 @@ export class LocalDevServer implements DevServer {
124134

125135
getMostRecentBuild() {
126136
return {
127-
status: this.latestBuildStatus ?? 'unknown',
128-
logs: this.serverLogs.slice(this.latestBuildLogStartIndex, this.latestBuildLogEndIndex),
137+
status: this.latestBuildStatus,
138+
logs: this.serverLogs.slice(this.latestBuildLogStartIndex),
129139
};
130140
}
131141

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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';
@@ -24,29 +25,29 @@ import { STOP_DEVSERVER_TOOL } from './tools/stop-devserver';
2425
import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2526
import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/wait-for-devserver-build';
2627

28+
/**
29+
* Tools to manage devservers. Should be bundled together.
30+
*/
31+
const SERVE_TOOLS = [START_DEVSERVER_TOOL, STOP_DEVSERVER_TOOL, WAIT_FOR_DEVSERVER_BUILD_TOOL];
32+
2733
/**
2834
* The set of tools that are enabled by default for the MCP server.
2935
* These tools are considered stable and suitable for general use.
3036
*/
3137
const STABLE_TOOLS = [
3238
AI_TUTOR_TOOL,
3339
BEST_PRACTICES_TOOL,
34-
BUILD_TOOL,
3540
DOC_SEARCH_TOOL,
3641
FIND_EXAMPLE_TOOL,
3742
LIST_PROJECTS_TOOL,
3843
] as const;
3944

40-
/**
41-
* Tools to manage devservers. Should be bundled together.
42-
*/
43-
const SERVE_TOOLS = [START_DEVSERVER_TOOL, STOP_DEVSERVER_TOOL, WAIT_FOR_DEVSERVER_BUILD_TOOL];
44-
4545
/**
4646
* The set of tools that are available but not enabled by default.
4747
* These tools are considered experimental and may have limitations.
4848
*/
4949
export const EXPERIMENTAL_TOOLS = [
50+
BUILD_TOOL,
5051
MODERNIZE_TOOL,
5152
ZONELESS_MIGRATION_TOOL,
5253
...SERVE_TOOLS,
@@ -117,6 +118,7 @@ equivalent actions.
117118
workspace: options.workspace,
118119
logger,
119120
exampleDatabasePath: path.join(__dirname, '../../../lib/code-examples.db'),
121+
devServers: new Map<string, DevServer>(),
120122
},
121123
toolDeclarations,
122124
);

packages/angular/cli/src/commands/mcp/tools/build.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { z } from 'zod';
1010
import { CommandError, Host, LocalWorkspaceHost } from '../host';
1111
import { createStructureContentOutput } from '../utils';
12-
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
12+
import { McpToolDeclaration, declareTool } from './tool-registry';
1313

1414
const CONFIGURATIONS = {
1515
development: {
@@ -50,6 +50,7 @@ export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
5050
export async function runBuild(input: BuildToolInput, host: Host) {
5151
const configurationName = input.configuration ?? 'development';
5252
const configuration = CONFIGURATIONS[configurationName as keyof typeof CONFIGURATIONS];
53+
5354
const args = ['build'];
5455
if (input.project) {
5556
args.push(input.project);
@@ -99,18 +100,18 @@ export const BUILD_TOOL: McpToolDeclaration<
99100
title: 'Build Tool',
100101
description: `
101102
<Purpose>
102-
Perform a one-off, non-watched build with "ng build". Use this tool whenever
103-
the user wants to build an Angular project; this is similar to "ng build", but
104-
the tool is smarter about using the right configuration and collecting the
105-
output logs.
103+
Perform a one-off, non-watched build with "ng build". Use this tool whenever the user wants to build an Angular project; this is similar to
104+
"ng build", but the tool is smarter about using the right configuration and collecting the output logs.
106105
</Purpose>
107106
<Use Cases>
108107
* Building the Angular project and getting build logs back.
109108
</Use Cases>
110109
<Operational Notes>
111110
* This tool runs "ng build" so it expects to run within an Angular workspace.
112-
* You can provide a project instead of building the root one. The
113-
"list_projects" MCP tool could be used to obtain the list of projects.
111+
* If you want a watched build which updates as files are changed, use "start_devserver" instead, which also serves the app.
112+
* 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.
113+
* This tool defaults to a development environment while a regular "ng build" defaults to a production environment. An unexpected build
114+
failure might suggest the project is not configured for the requested environment.
114115
</Operational Notes>
115116
`,
116117
isReadOnly: false,

packages/angular/cli/src/commands/mcp/tools/start-devserver.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { createServer } from 'net';
910
import { z } from 'zod';
10-
import { DevServer, LocalDevServer } from '../dev-server';
11+
import { LocalDevServer, devServerKey } from '../dev-server';
1112
import { Host, LocalWorkspaceHost } from '../host';
1213
import { createStructureContentOutput } from '../utils';
1314
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
@@ -19,37 +20,76 @@ const startDevserverToolInputSchema = z.object({
1920
.describe(
2021
'Which project to serve in a monorepo context. If not provided, serves the default project.',
2122
),
22-
port: z.number().optional().describe('Port to listen on.'),
2323
});
2424

2525
export type StartDevserverToolInput = z.infer<typeof startDevserverToolInputSchema>;
2626

2727
const startDevserverToolOutputSchema = z.object({
2828
message: z.string().describe('A message indicating the result of the operation.'),
29+
address: z
30+
.string()
31+
.optional()
32+
.describe(
33+
'If the operation was successful, this is the HTTP address that the server can be found at.',
34+
),
2935
});
3036

3137
export type StartDevserverToolOutput = z.infer<typeof startDevserverToolOutputSchema>;
3238

33-
export const DEFAULT_PROJECT_KEY = '<default>';
39+
/**
40+
* Finds an available TCP port on the system.
41+
*/
42+
function getAvailablePort(): Promise<number> {
43+
return new Promise((resolve, reject) => {
44+
// Create a new temporary server from Node's net library.
45+
const server = createServer();
46+
47+
server.once('error', (err: unknown) => {
48+
reject(err);
49+
});
50+
51+
// Listen on port 0 to let the OS assign an available port.
52+
server.listen(0, () => {
53+
const address = server.address();
54+
55+
// Ensure address is an object with a port property.
56+
if (address && typeof address === 'object') {
57+
const port = address.port;
3458

35-
function startDevserver(input: StartDevserverToolInput, context: McpToolContext, host: Host) {
36-
const projectKey = input.project ?? DEFAULT_PROJECT_KEY;
37-
if (context.devServers?.has(projectKey)) {
59+
server.close();
60+
resolve(port);
61+
} else {
62+
reject(new Error('Unable to retrieve address information from server.'));
63+
}
64+
});
65+
});
66+
}
67+
68+
function localhostAddress(port: number) {
69+
return `http://localhost:${port}/`;
70+
}
71+
72+
async function startDevserver(input: StartDevserverToolInput, context: McpToolContext, host: Host) {
73+
const projectKey = devServerKey(input.project);
74+
75+
let devServer = context.devServers.get(projectKey);
76+
if (devServer) {
3877
return createStructureContentOutput({
3978
message: `Development server for project '${projectKey}' is already running.`,
79+
address: localhostAddress(devServer.port),
4080
});
4181
}
4282

43-
const devServer = new LocalDevServer({ host, project: input.project, port: input.port });
83+
const port = await getAvailablePort();
84+
85+
devServer = new LocalDevServer({ host, project: input.project, port });
4486
devServer.start();
4587

46-
if (!context.devServers) {
47-
context.devServers = new Map<string, DevServer>();
48-
}
4988
context.devServers.set(projectKey, devServer);
5089

5190
return createStructureContentOutput({
52-
message: `Development server for project '${projectKey}' started.`,
91+
message: `Development server for project '${projectKey}' started and watching for workspace changes.`,
92+
address: localhostAddress(port),
5393
});
5494
}
5595

@@ -64,12 +104,14 @@ export const START_DEVSERVER_TOOL: McpToolDeclaration<
64104
Starts the Angular development server ("ng serve") as a background process.
65105
</Purpose>
66106
<Use Cases>
67-
* **Starting the Server:** Use this tool to begin serving the application. The tool will return immediately
68-
while the server runs in the background.
107+
* **Starting the Server:** Use this tool to begin serving the application. The tool will return immediately while the server runs in the
108+
background.
109+
* **Get 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
110+
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.
69111
</Use Cases>
70112
<Operational Notes>
71-
* This tool manages a development server instance for each project.
72-
* This is an asynchronous operation. Subsequent commands can be run while the server is active.
113+
* This tool manages development servers by itself. It maintains at most a single dev server instance for each project in the monorepo.
114+
* This is an asynchronous operation. Subsequent commands can be ran while the server is active.
73115
* Use 'stop_devserver' to gracefully shut down the server and access the full log output.
74116
</Operational Notes>
75117
`,

packages/angular/cli/src/commands/mcp/tools/stop-devserver.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import { z } from 'zod';
10+
import { devServerKey } from '../dev-server';
1011
import { createStructureContentOutput } from '../utils';
11-
import { DEFAULT_PROJECT_KEY } from './start-devserver';
1212
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
1313

1414
const stopDevserverToolInputSchema = z.object({
@@ -24,14 +24,14 @@ export type StopDevserverToolInput = z.infer<typeof stopDevserverToolInputSchema
2424

2525
const stopDevserverToolOutputSchema = z.object({
2626
message: z.string().describe('A message indicating the result of the operation.'),
27-
logs: z.array(z.string()).optional().describe('The logs from the dev server.'),
27+
logs: z.array(z.string()).optional().describe('The full logs from the dev server.'),
2828
});
2929

3030
export type StopDevserverToolOutput = z.infer<typeof stopDevserverToolOutputSchema>;
3131

3232
function stopDevserver(input: StopDevserverToolInput, context: McpToolContext) {
33-
const projectKey = input.project ?? DEFAULT_PROJECT_KEY;
34-
const devServer = context.devServers?.get(projectKey);
33+
const projectKey = devServerKey(input.project);
34+
const devServer = context.devServers.get(projectKey);
3535

3636
if (!devServer) {
3737
return createStructureContentOutput({
@@ -40,7 +40,7 @@ function stopDevserver(input: StopDevserverToolInput, context: McpToolContext) {
4040
}
4141

4242
devServer.stop();
43-
context.devServers?.delete(projectKey);
43+
context.devServers.delete(projectKey);
4444

4545
return createStructureContentOutput({
4646
message: `Development server for project '${projectKey}' stopped.`,
@@ -56,7 +56,7 @@ export const STOP_DEVSERVER_TOOL: McpToolDeclaration<
5656
title: 'Stop Development Server',
5757
description: `
5858
<Purpose>
59-
Stops a running Angular development server ("ng serve").
59+
Stops a running Angular development server ("ng serve") that was started with the "start_devserver" tool.
6060
</Purpose>
6161
<Use Cases>
6262
* **Stopping the Server:** Use this tool to terminate a running development server and retrieve the logs.

packages/angular/cli/src/commands/mcp/tools/tool-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface McpToolContext {
1818
workspace?: AngularWorkspace;
1919
logger: { warn(text: string): void };
2020
exampleDatabasePath?: string;
21-
devServers?: Map<string, DevServer>;
21+
devServers: Map<string, DevServer>;
2222
}
2323

2424
export type McpToolFactory<TInput extends ZodRawShape> = (

0 commit comments

Comments
 (0)