Skip to content

Commit b98a089

Browse files
committed
feat(@angular/cli): standardize MCP tools around workspace/project options
1 parent 630584e commit b98a089

File tree

17 files changed

+722
-427
lines changed

17 files changed

+722
-427
lines changed

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

Lines changed: 33 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,7 @@ 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+
}
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: 19 additions & 17 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,20 +38,23 @@ 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+
mcpWorkspace: context.workspace,
47+
});
48+
4349
// 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);
50+
const args = ['build', projectName, '-c', input.configuration ?? DEFAULT_CONFIGURATION];
4951

5052
let status: BuildStatus = 'success';
5153
let logs: string[] = [];
5254
let outputPath: string | undefined;
5355

5456
try {
55-
logs = (await host.runCommand('ng', args)).logs;
57+
logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs;
5658
} catch (e) {
5759
status = 'failure';
5860
logs = getCommandErrorLogs(e);
@@ -101,5 +103,5 @@ Perform a one-off, non-watched build using "ng build". Use this tool whenever th
101103
isLocalOnly: true,
102104
inputSchema: buildToolInputSchema.shape,
103105
outputSchema: buildToolOutputSchema.shape,
104-
factory: (context) => (input) => runBuild(input, context.host),
106+
factory: (context) => (input) => runBuild(input, context),
105107
});

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);

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

Lines changed: 19 additions & 17 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,16 @@ 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+
mcpWorkspace: context.workspace,
43+
});
4344

44-
if (!projectName) {
45-
return createStructuredContentOutput({
46-
message: ['Project name not provided, and no default project found.'],
47-
});
48-
}
45+
const key = getDevserverKey(workspacePath, projectName);
4946

50-
let devserver = context.devservers.get(projectName);
47+
let devserver = context.devservers.get(key);
5148
if (devserver) {
5249
return createStructuredContentOutput({
5350
message: `Development server for project '${projectName}' is already running.`,
@@ -57,10 +54,15 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc
5754

5855
const port = await context.host.getAvailablePort();
5956

60-
devserver = new LocalDevserver({ host: context.host, project: input.project, port });
57+
devserver = new LocalDevserver({
58+
host: context.host,
59+
project: projectName,
60+
port,
61+
workspacePath,
62+
});
6163
devserver.start();
6264

63-
context.devservers.set(projectName, devserver);
65+
context.devservers.set(key, devserver);
6466

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

0 commit comments

Comments
 (0)