Skip to content

Commit 332792d

Browse files
committed
feat(@angular/cli): standardize MCP tools around workspace/project options
1 parent 8d99355 commit 332792d

File tree

16 files changed

+657
-345
lines changed

16 files changed

+657
-345
lines changed

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,29 @@ export class LocalDevserver implements Devserver {
7171
readonly host: Host;
7272
readonly port: number;
7373
readonly project?: string;
74+
readonly workspacePath?: string;
7475

7576
private devserverProcess: ChildProcess | null = null;
7677
private serverLogs: string[] = [];
7778
private buildInProgress = false;
7879
private latestBuildLogStartIndex?: number = undefined;
7980
private latestBuildStatus: BuildStatus = 'unknown';
8081

81-
constructor({ host, port, project }: { host: Host; port: number; project?: string }) {
82+
constructor({
83+
host,
84+
port,
85+
project,
86+
workspacePath,
87+
}: {
88+
host: Host;
89+
port: number;
90+
project?: string;
91+
workspacePath?: string;
92+
}) {
8293
this.host = host;
8394
this.project = project;
8495
this.port = port;
96+
this.workspacePath = workspacePath;
8597
}
8698

8799
start() {
@@ -96,7 +108,10 @@ export class LocalDevserver implements Devserver {
96108

97109
args.push(`--port=${this.port}`);
98110

99-
this.devserverProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
111+
this.devserverProcess = this.host.spawn('ng', args, {
112+
stdio: 'pipe',
113+
cwd: this.workspacePath,
114+
});
100115
this.devserverProcess.stdout?.on('data', (data) => {
101116
this.addLog(data.toString());
102117
});
@@ -142,3 +157,7 @@ export class LocalDevserver implements Devserver {
142157
return this.buildInProgress;
143158
}
144159
}
160+
161+
export function getDevServerKey(workspacePath: string, projectName: string): string {
162+
return `${workspacePath}:${projectName}`;
163+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
11+
export const workspaceAndProjectOptions = {
12+
project: z
13+
.string()
14+
.optional()
15+
.describe(
16+
'Which project to target in a monorepo context. If not provided, targets the default project.',
17+
),
18+
workspace: z
19+
.string()
20+
.optional()
21+
.describe('The path to the workspace directory (containing angular.json).'),
22+
};

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,21 @@
77
*/
88

99
import { z } from 'zod';
10-
import { CommandError, type Host } from '../host';
11-
import { createStructuredContentOutput, getCommandErrorLogs } from '../utils';
12-
import { type McpToolDeclaration, declareTool } from './tool-registry';
10+
import { workspaceAndProjectOptions } from '../shared-options';
11+
import {
12+
createStructuredContentOutput,
13+
getCommandErrorLogs,
14+
resolveWorkspaceAndProject,
15+
} from '../utils';
16+
import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry';
1317

1418
const DEFAULT_CONFIGURATION = 'development';
1519

1620
const buildStatusSchema = z.enum(['success', 'failure']);
1721
type BuildStatus = z.infer<typeof buildStatusSchema>;
1822

1923
const buildToolInputSchema = z.object({
20-
project: z
21-
.string()
22-
.optional()
23-
.describe(
24-
'Which project to build in a monorepo context. If not provided, builds the default project.',
25-
),
24+
...workspaceAndProjectOptions,
2625
configuration: z
2726
.string()
2827
.optional()
@@ -39,11 +38,18 @@ const buildToolOutputSchema = z.object({
3938

4039
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
4140

42-
export async function runBuild(input: BuildToolInput, host: Host) {
41+
export async function runBuild(input: BuildToolInput, context: McpToolContext) {
42+
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
43+
host: context.host,
44+
workspacePathInput: input.workspace,
45+
projectNameInput: input.project,
46+
loadedWorkspace: context.workspace,
47+
});
48+
4349
// Build "ng"'s command line.
4450
const args = ['build'];
45-
if (input.project) {
46-
args.push(input.project);
51+
if (projectName) {
52+
args.push(projectName);
4753
}
4854
args.push('-c', input.configuration ?? DEFAULT_CONFIGURATION);
4955

@@ -52,7 +58,7 @@ export async function runBuild(input: BuildToolInput, host: Host) {
5258
let outputPath: string | undefined;
5359

5460
try {
55-
logs = (await host.runCommand('ng', args)).logs;
61+
logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs;
5662
} catch (e) {
5763
status = 'failure';
5864
logs = getCommandErrorLogs(e);
@@ -101,5 +107,5 @@ Perform a one-off, non-watched build using "ng build". Use this tool whenever th
101107
isLocalOnly: true,
102108
inputSchema: buildToolInputSchema.shape,
103109
outputSchema: buildToolOutputSchema.shape,
104-
factory: (context) => (input) => runBuild(input, context.host),
110+
factory: (context) => (input) => runBuild(input, context),
105111
});

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

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

9+
import { AngularWorkspace } from '../../../utilities/config';
910
import { CommandError } from '../host';
1011
import type { MockHost } from '../testing/mock-host';
11-
import { createMockHost } from '../testing/test-utils';
12+
import { addProjectToWorkspace, createMockContext } from '../testing/test-utils';
1213
import { runBuild } from './build';
14+
import type { McpToolContext } from './tool-registry';
1315

1416
describe('Build Tool', () => {
1517
let mockHost: MockHost;
18+
let mockContext: McpToolContext;
19+
let mockWorkspace: AngularWorkspace;
1620

1721
beforeEach(() => {
18-
mockHost = createMockHost();
22+
const mock = createMockContext();
23+
mockHost = mock.host;
24+
mockContext = mock.context;
25+
mockWorkspace = mock.workspace;
26+
addProjectToWorkspace(mock.projects, 'my-app');
1927
});
2028

2129
it('should construct the command correctly with default configuration', async () => {
22-
await runBuild({}, mockHost);
23-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'development']);
30+
mockWorkspace.extensions['defaultProject'] = 'my-app';
31+
await runBuild({}, mockContext);
32+
expect(mockHost.runCommand).toHaveBeenCalledWith(
33+
'ng',
34+
['build', 'my-app', '-c', 'development'],
35+
{ cwd: '/test' },
36+
);
2437
});
2538

2639
it('should construct the command correctly with a specified project', async () => {
27-
await runBuild({ project: 'another-app' }, mockHost);
28-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
29-
'build',
30-
'another-app',
31-
'-c',
32-
'development',
33-
]);
40+
addProjectToWorkspace(mockWorkspace.projects, 'another-app');
41+
await runBuild({ project: 'another-app' }, mockContext);
42+
expect(mockHost.runCommand).toHaveBeenCalledWith(
43+
'ng',
44+
['build', 'another-app', '-c', 'development'],
45+
{ cwd: '/test' },
46+
);
3447
});
3548

3649
it('should construct the command correctly for a custom configuration', async () => {
37-
await runBuild({ configuration: 'myconfig' }, mockHost);
38-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'myconfig']);
50+
mockWorkspace.extensions['defaultProject'] = 'my-app';
51+
await runBuild({ configuration: 'myconfig' }, mockContext);
52+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], {
53+
cwd: '/test',
54+
});
3955
});
4056

4157
it('should handle a successful build and extract the output path and logs', async () => {
@@ -49,35 +65,34 @@ describe('Build Tool', () => {
4965
logs: buildLogs,
5066
});
5167

52-
const { structuredContent } = await runBuild({ project: 'my-app' }, mockHost);
68+
const { structuredContent } = await runBuild({ project: 'my-app' }, mockContext);
5369

54-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
55-
'build',
56-
'my-app',
57-
'-c',
58-
'development',
59-
]);
70+
expect(mockHost.runCommand).toHaveBeenCalledWith(
71+
'ng',
72+
['build', 'my-app', '-c', 'development'],
73+
{ cwd: '/test' },
74+
);
6075
expect(structuredContent.status).toBe('success');
6176
expect(structuredContent.logs).toEqual(buildLogs);
6277
expect(structuredContent.path).toBe('dist/my-app');
6378
});
6479

6580
it('should handle a failed build and capture logs', async () => {
81+
addProjectToWorkspace(mockWorkspace.projects, 'my-failed-app');
6682
const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!'];
6783
const error = new CommandError('Build failed', buildLogs, 1);
6884
mockHost.runCommand.and.rejectWith(error);
6985

7086
const { structuredContent } = await runBuild(
7187
{ project: 'my-failed-app', configuration: 'production' },
72-
mockHost,
88+
mockContext,
7389
);
7490

75-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
76-
'build',
77-
'my-failed-app',
78-
'-c',
79-
'production',
80-
]);
91+
expect(mockHost.runCommand).toHaveBeenCalledWith(
92+
'ng',
93+
['build', 'my-failed-app', '-c', 'production'],
94+
{ cwd: '/test' },
95+
);
8196
expect(structuredContent.status).toBe('failure');
8297
expect(structuredContent.logs).toEqual([...buildLogs, 'Build failed']);
8398
expect(structuredContent.path).toBeUndefined();
@@ -87,7 +102,8 @@ describe('Build Tool', () => {
87102
const buildLogs = ["Some logs that don't match any output path."];
88103
mockHost.runCommand.and.resolveTo({ logs: buildLogs });
89104

90-
const { structuredContent } = await runBuild({}, mockHost);
105+
mockWorkspace.extensions['defaultProject'] = 'my-app';
106+
const { structuredContent } = await runBuild({}, mockContext);
91107

92108
expect(structuredContent.status).toBe('success');
93109
expect(structuredContent.logs).toEqual(buildLogs);

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,13 @@
77
*/
88

99
import { z } from 'zod';
10-
import { LocalDevserver } from '../../devserver';
11-
import { createStructuredContentOutput, getDefaultProjectName } from '../../utils';
10+
import { LocalDevserver, getDevServerKey } from '../../devserver';
11+
import { workspaceAndProjectOptions } from '../../shared-options';
12+
import { createStructuredContentOutput, resolveWorkspaceAndProject } from '../../utils';
1213
import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry';
1314

1415
const devserverStartToolInputSchema = z.object({
15-
project: z
16-
.string()
17-
.optional()
18-
.describe(
19-
'Which project to serve in a monorepo context. If not provided, serves the default project.',
20-
),
16+
...workspaceAndProjectOptions,
2117
});
2218

2319
export type DevserverStartToolInput = z.infer<typeof devserverStartToolInputSchema>;
@@ -39,15 +35,22 @@ function localhostAddress(port: number) {
3935
}
4036

4137
export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) {
42-
const projectName = input.project ?? getDefaultProjectName(context);
38+
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
39+
host: context.host,
40+
workspacePathInput: input.workspace,
41+
projectNameInput: input.project,
42+
loadedWorkspace: context.workspace,
43+
});
4344

4445
if (!projectName) {
4546
return createStructuredContentOutput({
4647
message: ['Project name not provided, and no default project found.'],
4748
});
4849
}
4950

50-
let devserver = context.devservers.get(projectName);
51+
const key = getDevServerKey(workspacePath, projectName);
52+
53+
let devserver = context.devservers.get(key);
5154
if (devserver) {
5255
return createStructuredContentOutput({
5356
message: `Development server for project '${projectName}' is already running.`,
@@ -57,10 +60,15 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc
5760

5861
const port = await context.host.getAvailablePort();
5962

60-
devserver = new LocalDevserver({ host: context.host, project: input.project, port });
63+
devserver = new LocalDevserver({
64+
host: context.host,
65+
project: input.project,
66+
port,
67+
workspacePath,
68+
});
6169
devserver.start();
6270

63-
context.devservers.set(projectName, devserver);
71+
context.devservers.set(key, devserver);
6472

6573
return createStructuredContentOutput({
6674
message: `Development server for project '${projectName}' started and watching for workspace changes.`,

0 commit comments

Comments
 (0)