Skip to content

Commit 4e33d10

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

File tree

17 files changed

+711
-414
lines changed

17 files changed

+711
-414
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 mockContext: 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: mockContext, projects };
7076
}
7177

7278
/**

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: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,53 @@
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 {
13+
MockMcpToolContext,
14+
addProjectToWorkspace,
15+
createMockContext,
16+
} from '../testing/test-utils';
1217
import { runBuild } from './build';
1318

1419
describe('Build Tool', () => {
1520
let mockHost: MockHost;
21+
let mockContext: MockMcpToolContext;
1622

1723
beforeEach(() => {
18-
mockHost = createMockHost();
24+
const mock = createMockContext();
25+
mockHost = mock.host;
26+
mockContext = mock.context;
27+
addProjectToWorkspace(mock.projects, 'my-app');
1928
});
2029

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

2640
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-
]);
41+
addProjectToWorkspace(mockContext.workspace.projects, 'another-app');
42+
await runBuild({ project: 'another-app' }, mockContext);
43+
expect(mockHost.runCommand).toHaveBeenCalledWith(
44+
'ng',
45+
['build', 'another-app', '-c', 'development'],
46+
{ cwd: '/test' },
47+
);
3448
});
3549

3650
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']);
51+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
52+
await runBuild({ configuration: 'myconfig' }, mockContext);
53+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], {
54+
cwd: '/test',
55+
});
3956
});
4057

4158
it('should handle a successful build and extract the output path and logs', async () => {
@@ -49,35 +66,34 @@ describe('Build Tool', () => {
4966
logs: buildLogs,
5067
});
5168

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

54-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
55-
'build',
56-
'my-app',
57-
'-c',
58-
'development',
59-
]);
71+
expect(mockHost.runCommand).toHaveBeenCalledWith(
72+
'ng',
73+
['build', 'my-app', '-c', 'development'],
74+
{ cwd: '/test' },
75+
);
6076
expect(structuredContent.status).toBe('success');
6177
expect(structuredContent.logs).toEqual(buildLogs);
6278
expect(structuredContent.path).toBe('dist/my-app');
6379
});
6480

6581
it('should handle a failed build and capture logs', async () => {
82+
addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app');
6683
const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!'];
6784
const error = new CommandError('Build failed', buildLogs, 1);
6885
mockHost.runCommand.and.rejectWith(error);
6986

7087
const { structuredContent } = await runBuild(
7188
{ project: 'my-failed-app', configuration: 'production' },
72-
mockHost,
89+
mockContext,
7390
);
7491

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

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

92109
expect(structuredContent.status).toBe('success');
93110
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+
loadedWorkspace: 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)