Skip to content

Commit 98a24d0

Browse files
amishnealan-agius4
authored andcommitted
feat(@angular/cli): standardize MCP tools around workspace/project options
1 parent 1f1b21d commit 98a24d0

File tree

20 files changed

+839
-580
lines changed

20 files changed

+839
-580
lines changed

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

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ export interface Devserver {
6262
* `ng serve` port to use.
6363
*/
6464
port: number;
65+
66+
/**
67+
* The workspace path for this server.
68+
*/
69+
workspacePath: string;
70+
71+
/**
72+
* The project name for this server.
73+
*/
74+
project: string;
6575
}
6676

6777
/**
@@ -70,18 +80,30 @@ export interface Devserver {
7080
export class LocalDevserver implements Devserver {
7181
readonly host: Host;
7282
readonly port: number;
73-
readonly project?: string;
83+
readonly workspacePath: string;
84+
readonly project: string;
7485

7586
private devserverProcess: ChildProcess | null = null;
7687
private serverLogs: string[] = [];
7788
private buildInProgress = false;
7889
private latestBuildLogStartIndex?: number = undefined;
7990
private latestBuildStatus: BuildStatus = 'unknown';
8091

81-
constructor({ host, port, project }: { host: Host; port: number; project?: string }) {
92+
constructor({
93+
host,
94+
port,
95+
workspacePath,
96+
project,
97+
}: {
98+
host: Host;
99+
port: number;
100+
workspacePath: string;
101+
project: string;
102+
}) {
82103
this.host = host;
83-
this.project = project;
84104
this.port = port;
105+
this.workspacePath = workspacePath;
106+
this.project = project;
85107
}
86108

87109
start() {
@@ -96,7 +118,10 @@ export class LocalDevserver implements Devserver {
96118

97119
args.push(`--port=${this.port}`);
98120

99-
this.devserverProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
121+
this.devserverProcess = this.host.spawn('ng', args, {
122+
stdio: 'pipe',
123+
cwd: this.workspacePath,
124+
});
100125
this.devserverProcess.stdout?.on('data', (data) => {
101126
this.addLog(data.toString());
102127
});
@@ -142,3 +167,24 @@ export class LocalDevserver implements Devserver {
142167
return this.buildInProgress;
143168
}
144169
}
170+
171+
export function getDevserverKey(workspacePath: string, projectName: string): string {
172+
return `${workspacePath}:${projectName}`;
173+
}
174+
175+
export function createDevServerNotFoundError(
176+
devservers: Map<string, { project: string; workspacePath: string }>,
177+
): Error {
178+
if (devservers.size === 0) {
179+
return new Error('No development servers are currently running.');
180+
}
181+
182+
const runningServers = Array.from(devservers.values())
183+
.map((server) => `- Project '${server.project}' in workspace path '${server.workspacePath}'`)
184+
.join('\n');
185+
186+
return new Error(
187+
`Dev server not found. Currently running servers:\n${runningServers}\n` +
188+
'Please provide the correct workspace and project arguments.',
189+
);
190+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
workspace: z
13+
.string()
14+
.optional()
15+
.describe(
16+
'The path to the workspace directory (containing angular.json). If not provided, uses the current directory.',
17+
),
18+
project: z
19+
.string()
20+
.optional()
21+
.describe(
22+
'Which project to target in a monorepo context. If not provided, targets the default project.',
23+
),
24+
};

packages/angular/cli/src/commands/mcp/testing/test-utils.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export interface MockContextOptions {
4141
projects?: Record<string, workspaces.ProjectDefinition>;
4242
}
4343

44+
/**
45+
* Same as McpToolContext, just with guaranteed nonnull workspace.
46+
*/
47+
export interface MockMcpToolContext extends McpToolContext {
48+
workspace: AngularWorkspace;
49+
}
50+
4451
/**
4552
* Creates a comprehensive mock for the McpToolContext, including a mock Host,
4653
* an AngularWorkspace, and a ProjectDefinitionCollection. This simplifies testing
@@ -50,23 +57,22 @@ export interface MockContextOptions {
5057
*/
5158
export function createMockContext(options: MockContextOptions = {}): {
5259
host: MockHost;
53-
context: McpToolContext;
60+
context: MockMcpToolContext;
5461
projects: workspaces.ProjectDefinitionCollection;
55-
workspace: AngularWorkspace;
5662
} {
5763
const host = options.host ?? createMockHost();
5864
const projects = new workspaces.ProjectDefinitionCollection(options.projects);
5965
const workspace = new AngularWorkspace({ projects, extensions: {} }, '/test/angular.json');
6066

61-
const context: McpToolContext = {
67+
const context: MockMcpToolContext = {
6268
server: {} as unknown as McpServer,
6369
workspace,
6470
logger: { warn: () => {} },
6571
devservers: new Map<string, Devserver>(),
6672
host,
6773
};
6874

69-
return { host, context, projects, workspace };
75+
return { host, context, projects };
7076
}
7177

7278
/**

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

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,18 @@
77
*/
88

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

1415
const DEFAULT_CONFIGURATION = 'development';
1516

1617
const buildStatusSchema = z.enum(['success', 'failure']);
1718
type BuildStatus = z.infer<typeof buildStatusSchema>;
1819

1920
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-
),
21+
...workspaceAndProjectOptions,
2622
configuration: z
2723
.string()
2824
.optional()
@@ -39,20 +35,23 @@ const buildToolOutputSchema = z.object({
3935

4036
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
4137

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

5049
let status: BuildStatus = 'success';
5150
let logs: string[] = [];
5251
let outputPath: string | undefined;
5352

5453
try {
55-
logs = (await host.runCommand('ng', args)).logs;
54+
logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs;
5655
} catch (e) {
5756
status = 'failure';
5857
logs = getCommandErrorLogs(e);
@@ -101,5 +100,5 @@ Perform a one-off, non-watched build using "ng build". Use this tool whenever th
101100
isLocalOnly: true,
102101
inputSchema: buildToolInputSchema.shape,
103102
outputSchema: buildToolOutputSchema.shape,
104-
factory: (context) => (input) => runBuild(input, context.host),
103+
factory: (context) => (input) => runBuild(input, context),
105104
});

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

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,50 @@
88

99
import { CommandError } from '../host';
1010
import type { MockHost } from '../testing/mock-host';
11-
import { createMockHost } from '../testing/test-utils';
11+
import {
12+
MockMcpToolContext,
13+
addProjectToWorkspace,
14+
createMockContext,
15+
} from '../testing/test-utils';
1216
import { runBuild } from './build';
1317

1418
describe('Build Tool', () => {
1519
let mockHost: MockHost;
20+
let mockContext: MockMcpToolContext;
1621

1722
beforeEach(() => {
18-
mockHost = createMockHost();
23+
const mock = createMockContext();
24+
mockHost = mock.host;
25+
mockContext = mock.context;
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+
mockContext.workspace.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(mockContext.workspace.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+
mockContext.workspace.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(mockContext.workspace.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+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
106+
const { structuredContent } = await runBuild({}, mockContext);
91107

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

0 commit comments

Comments
 (0)