Skip to content

Commit 50caa1d

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

File tree

3 files changed

+205
-0
lines changed

3 files changed

+205
-0
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { VERSION } from '../../utilities/version';
1313
import { registerInstructionsResource } from './resources/instructions';
1414
import { AI_TUTOR_TOOL } from './tools/ai-tutor';
1515
import { BEST_PRACTICES_TOOL } from './tools/best-practices';
16+
import { BUILD_TOOL } from './tools/build';
1617
import { DOC_SEARCH_TOOL } from './tools/doc-search';
1718
import { FIND_EXAMPLE_TOOL } from './tools/examples';
1819
import { MODERNIZE_TOOL } from './tools/modernize';
@@ -27,6 +28,7 @@ import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2728
const STABLE_TOOLS = [
2829
AI_TUTOR_TOOL,
2930
BEST_PRACTICES_TOOL,
31+
BUILD_TOOL,
3032
DOC_SEARCH_TOOL,
3133
FIND_EXAMPLE_TOOL,
3234
LIST_PROJECTS_TOOL,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 { z } from 'zod';
10+
import { execSync } from 'child_process';
11+
import { declareTool } from './tool-registry';
12+
13+
const CONFIGURATIONS = {
14+
development: {
15+
args: '-c development',
16+
},
17+
production: {
18+
args: '',
19+
},
20+
};
21+
22+
const BUILD_STATUSES = ['success', 'failure'] as const;
23+
type BuildStatus = (typeof BUILD_STATUSES)[number];
24+
25+
const buildToolInputSchema = z.object({
26+
project: z
27+
.string()
28+
.optional()
29+
.describe(
30+
'Which project to build in a monorepo context. If not provided, builds the top-level project.',
31+
),
32+
configuration: z
33+
.enum(Object.keys(CONFIGURATIONS) as [string, ...string[]])
34+
.optional()
35+
.describe('Which build configuration to use. Defaults to "development".'),
36+
});
37+
38+
export type BuildToolInput = z.infer<typeof buildToolInputSchema>;
39+
40+
const buildToolOutputSchema = z.object({
41+
status: z.enum(BUILD_STATUSES).describe('Build status.'),
42+
logs: z.string().describe('Output logs from `ng build`.'),
43+
path: z.string().optional().describe('The output path the build project was written into.'),
44+
});
45+
46+
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
47+
48+
export function runBuild(input: BuildToolInput) {
49+
const configurationName = input.configuration ?? 'development';
50+
const configuration = CONFIGURATIONS[configurationName as keyof typeof CONFIGURATIONS];
51+
const command = ['ng', 'build'];
52+
if (input.project) {
53+
command.push(input.project);
54+
}
55+
if (configuration.args) {
56+
command.push(configuration.args);
57+
}
58+
59+
let status: BuildStatus = 'success';
60+
let logs = '';
61+
let outputPath: string | undefined;
62+
63+
try {
64+
logs = execSync(command.join(' ')).toString();
65+
const match = logs.match(/Output location: (.*)/);
66+
if (match) {
67+
outputPath = match[1].trim();
68+
}
69+
} catch (e) {
70+
status = 'failure';
71+
if (e instanceof Error) {
72+
logs = e.message;
73+
if ('stdout' in e) {
74+
logs += `\nSTDOUT:\n${(e as { stdout: Buffer }).stdout.toString()}`;
75+
}
76+
if ('stderr' in e) {
77+
logs += `\nSTDERR:\n${(e as { stderr: Buffer }).stderr.toString()}`;
78+
}
79+
}
80+
}
81+
82+
const structuredContent: BuildToolOutput = {
83+
status,
84+
logs,
85+
path: outputPath,
86+
};
87+
88+
return {
89+
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
90+
structuredContent,
91+
};
92+
}
93+
94+
export const BUILD_TOOL = declareTool({
95+
name: 'build',
96+
title: 'Build Tool',
97+
description: `
98+
<Purpose>
99+
Perform a one-off, non-watched build with "ng build". Use this tool whenever
100+
the user wants to build an Angular project; this is similar to "ng build", but
101+
the tool is smarter about using the right configuration and collecting the
102+
output logs.
103+
</Purpose>
104+
<Use Cases>
105+
* Building the Angular project and getting build logs back.
106+
</Use Cases>
107+
<Operational Notes>
108+
* This tool runs "ng build" so it expects to run within an Angular workspace.
109+
* You can provide a project instead of building the root one. The
110+
"list_projects" MCP tool could be used to obtain the list of projects.
111+
</Operational Notes>
112+
`,
113+
isReadOnly: false,
114+
isLocalOnly: true,
115+
inputSchema: buildToolInputSchema.shape,
116+
outputSchema: buildToolOutputSchema.shape,
117+
factory: () => (input: BuildToolInput) => runBuild(input),
118+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 { execSync, spawnSync } from 'child_process';
10+
import { BUILD_TOOL, BuildToolInput, runBuild } from './build';
11+
import { McpToolContext } from './tool-registry';
12+
13+
// Mock the execSync function
14+
const mockedExecSync = jasmine.createSpy('execSync');
15+
16+
// Replace the actual execSync with our mock
17+
Object.defineProperty(require('child_process'), 'execSync', {
18+
value: mockedExecSync,
19+
});
20+
21+
describe('Build Tool', () => {
22+
beforeEach(() => {
23+
mockedExecSync.calls.reset();
24+
});
25+
26+
it('should handle a successful build and extract the output path', () => {
27+
const buildLogs =
28+
'Build successful!\nSome other log lines...\nOutput location: dist/my-cool-app';
29+
mockedExecSync.and.returnValue(Buffer.from(buildLogs));
30+
31+
const result = runBuild({ project: 'my-cool-app' });
32+
33+
expect(mockedExecSync).toHaveBeenCalledWith('ng build my-cool-app -c development');
34+
expect(result.structuredContent.status).toBe('success');
35+
expect(result.structuredContent.logs).toBe(buildLogs);
36+
expect(result.structuredContent.path).toBe('dist/my-cool-app');
37+
});
38+
39+
it('should handle a failed build and capture logs', () => {
40+
const error = new Error('Build failed');
41+
// Errors thrown from execSync contain the entire result like it would be returned from spawnSync.
42+
const execSyncError = error as unknown as ReturnType<typeof spawnSync>;
43+
execSyncError.stdout = Buffer.from('Some output before the crash.');
44+
execSyncError.stderr = Buffer.from('Error: Something went wrong!');
45+
mockedExecSync.and.throwError(error);
46+
47+
const result = runBuild({ project: 'my-failed-app', configuration: 'production' });
48+
49+
expect(mockedExecSync).toHaveBeenCalledWith('ng build my-failed-app');
50+
expect(result.structuredContent.status).toBe('failure');
51+
expect(result.structuredContent.logs).toContain('Build failed');
52+
expect(result.structuredContent.logs).toContain('STDOUT:\nSome output before the crash.');
53+
expect(result.structuredContent.logs).toContain('STDERR:\nError: Something went wrong!');
54+
expect(result.structuredContent.path).toBeUndefined();
55+
});
56+
57+
it('should construct the command correctly with default configuration', () => {
58+
mockedExecSync.and.returnValue(Buffer.from('Success'));
59+
runBuild({});
60+
expect(mockedExecSync).toHaveBeenCalledWith('ng build -c development');
61+
});
62+
63+
it('should construct the command correctly with a specified project', () => {
64+
mockedExecSync.and.returnValue(Buffer.from('Success'));
65+
runBuild({ project: 'another-app' });
66+
expect(mockedExecSync).toHaveBeenCalledWith('ng build another-app -c development');
67+
});
68+
69+
it('should construct the command correctly for production configuration', () => {
70+
mockedExecSync.and.returnValue(Buffer.from('Success'));
71+
runBuild({ configuration: 'production' });
72+
expect(mockedExecSync).toHaveBeenCalledWith('ng build');
73+
});
74+
75+
it('should handle builds where the output path is not found in logs', () => {
76+
const buildLogs = 'Build finished, but we could not find the output path string.';
77+
mockedExecSync.and.returnValue(Buffer.from(buildLogs));
78+
79+
const result = runBuild({});
80+
81+
expect(result.structuredContent.status).toBe('success');
82+
expect(result.structuredContent.logs).toBe(buildLogs);
83+
expect(result.structuredContent.path).toBeUndefined();
84+
});
85+
});

0 commit comments

Comments
 (0)