Skip to content

Commit 44b2a02

Browse files
committed
refactor(@angular/cli): Make "build" MCP tool work with host
1 parent 2a18b31 commit 44b2a02

File tree

4 files changed

+120
-83
lines changed

4 files changed

+120
-83
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import { BUILD_TOOL } from './tools/build';
1717
import { DOC_SEARCH_TOOL } from './tools/doc-search';
1818
import { FIND_EXAMPLE_TOOL } from './tools/examples';
1919
import { MODERNIZE_TOOL } from './tools/modernize';
20-
import { SERVE_TOOL } from './tools/serve';
2120
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
2221
import { LIST_PROJECTS_TOOL } from './tools/projects';
22+
import { SERVE_TOOL } from './tools/serve';
2323
import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2424

2525
/**

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

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

99
import { z } from 'zod';
10-
import { execSync } from 'child_process';
11-
import { declareTool } from './tool-registry';
10+
import { CommandError, Host, LocalWorkspaceHost } from '../host';
11+
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
1212

1313
const CONFIGURATIONS = {
1414
development: {
@@ -39,59 +39,61 @@ export type BuildToolInput = z.infer<typeof buildToolInputSchema>;
3939

4040
const buildToolOutputSchema = z.object({
4141
status: z.enum(BUILD_STATUSES).describe('Build status.'),
42-
logs: z.string().describe('Output logs from `ng build`.'),
42+
stdout: z.string().optional().describe('The standard output from `ng build`.'),
43+
stderr: z.string().optional().describe('The standard error from `ng build`.'),
4344
path: z.string().optional().describe('The output path the build project was written into.'),
4445
});
4546

4647
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
4748

48-
export function runBuild(input: BuildToolInput) {
49+
export async function runBuild(input: BuildToolInput, host: Host) {
4950
const configurationName = input.configuration ?? 'development';
5051
const configuration = CONFIGURATIONS[configurationName as keyof typeof CONFIGURATIONS];
51-
const command = ['ng', 'build'];
52+
const args = ['build'];
5253
if (input.project) {
53-
command.push(input.project);
54+
args.push(input.project);
5455
}
5556
if (configuration.args) {
56-
command.push(configuration.args);
57+
args.push(configuration.args);
5758
}
5859

5960
let status: BuildStatus = 'success';
60-
let logs = '';
61+
let stdout = '';
62+
let stderr = '';
6163
let outputPath: string | undefined;
6264

6365
try {
64-
logs = execSync(command.join(' ')).toString();
65-
const match = logs.match(/Output location: (.*)/);
66+
const result = await host.runCommand('ng', args);
67+
stdout = result.stdout;
68+
stderr = result.stderr;
69+
const match = stdout.match(/Output location: (.*)/);
6670
if (match) {
6771
outputPath = match[1].trim();
6872
}
6973
} catch (e) {
7074
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-
}
75+
if (e instanceof CommandError) {
76+
stdout = e.stdout;
77+
stderr = e.stderr;
78+
} else if (e instanceof Error) {
79+
stderr = e.message;
7980
}
8081
}
8182

8283
const structuredContent: BuildToolOutput = {
8384
status,
84-
logs,
85+
stdout,
86+
stderr,
8587
path: outputPath,
8688
};
8789

88-
return {
89-
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
90-
structuredContent,
91-
};
90+
return createStructureContentOutput(structuredContent);
9291
}
9392

94-
export const BUILD_TOOL = declareTool({
93+
export const BUILD_TOOL: McpToolDeclaration<
94+
typeof buildToolInputSchema.shape,
95+
typeof buildToolOutputSchema.shape
96+
> = declareTool({
9597
name: 'build',
9698
title: 'Build Tool',
9799
description: `
@@ -114,5 +116,5 @@ output logs.
114116
isLocalOnly: true,
115117
inputSchema: buildToolInputSchema.shape,
116118
outputSchema: buildToolOutputSchema.shape,
117-
factory: () => (input: BuildToolInput) => runBuild(input),
119+
factory: () => (input) => runBuild(input, LocalWorkspaceHost),
118120
});

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

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

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-
});
9+
import { CommandError, Host } from '../host';
10+
import { BuildToolInput, runBuild } from './build';
2011

2112
describe('Build Tool', () => {
13+
let mockHost: Host;
14+
2215
beforeEach(() => {
23-
mockedExecSync.calls.reset();
16+
mockHost = {
17+
runCommand: jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' }),
18+
stat: jasmine.createSpy('stat'),
19+
existsSync: jasmine.createSpy('existsSync'),
20+
};
2421
});
2522

26-
it('should handle a successful build and extract the output path', () => {
27-
const buildLogs =
23+
it('should handle a successful build and extract the output path', async () => {
24+
const buildStdout =
2825
'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');
26+
(mockHost.runCommand as jasmine.Spy).and.resolveTo({
27+
stdout: buildStdout,
28+
stderr: 'some warning',
29+
});
30+
31+
const { structuredContent } = await runBuild({ project: 'my-cool-app' }, mockHost);
32+
33+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
34+
'build',
35+
'my-cool-app',
36+
'-c development',
37+
]);
38+
expect(structuredContent.status).toBe('success');
39+
expect(structuredContent.stdout).toBe(buildStdout);
40+
expect(structuredContent.stderr).toBe('some warning');
41+
expect(structuredContent.path).toBe('dist/my-cool-app');
3742
});
3843

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();
44+
it('should handle a failed build and capture logs', async () => {
45+
const error = new CommandError(
46+
'Build failed',
47+
'Some output before the crash.',
48+
'Error: Something went wrong!',
49+
1,
50+
);
51+
(mockHost.runCommand as jasmine.Spy).and.rejectWith(error);
52+
53+
const { structuredContent } = await runBuild(
54+
{ project: 'my-failed-app', configuration: 'production' },
55+
mockHost,
56+
);
57+
58+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-failed-app']);
59+
expect(structuredContent.status).toBe('failure');
60+
expect(structuredContent.stdout).toBe('Some output before the crash.');
61+
expect(structuredContent.stderr).toBe('Error: Something went wrong!');
62+
expect(structuredContent.path).toBeUndefined();
5563
});
5664

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');
65+
it('should construct the command correctly with default configuration', async () => {
66+
await runBuild({}, mockHost);
67+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c development']);
6168
});
6269

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');
70+
it('should construct the command correctly with a specified project', async () => {
71+
await runBuild({ project: 'another-app' }, mockHost);
72+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
73+
'build',
74+
'another-app',
75+
'-c development',
76+
]);
6777
});
6878

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');
79+
it('should construct the command correctly for production configuration', async () => {
80+
await runBuild({ configuration: 'production' }, mockHost);
81+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build']);
7382
});
7483

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));
84+
it('should handle builds where the output path is not found in logs', async () => {
85+
const buildStdout = 'Build finished, but we could not find the output path string.';
86+
(mockHost.runCommand as jasmine.Spy).and.resolveTo({ stdout: buildStdout, stderr: '' });
7887

79-
const result = runBuild({});
88+
const { structuredContent } = await runBuild({}, mockHost);
8089

81-
expect(result.structuredContent.status).toBe('success');
82-
expect(result.structuredContent.logs).toBe(buildLogs);
83-
expect(result.structuredContent.path).toBeUndefined();
90+
expect(structuredContent.status).toBe('success');
91+
expect(structuredContent.stdout).toBe(buildStdout);
92+
expect(structuredContent.path).toBeUndefined();
8493
});
8594
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
/**
10+
* @fileoverview
11+
* Utility functions shared across MCP tools.
12+
*/
13+
14+
/**
15+
* Returns simple structured content output from an MCP tool.
16+
*
17+
* Returns a structure with both `content` and `structuredContent` for maximum compatibility.
18+
* @param structuredContent
19+
* @returns
20+
*/
21+
function createStructureContentOutput<OutputType>(structuredContent: OutputType) {
22+
return {
23+
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent, null, 2) }],
24+
structuredContent,
25+
};
26+
}

0 commit comments

Comments
 (0)