Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions packages/angular/cli/src/commands/mcp/devserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export interface Devserver {
* `ng serve` port to use.
*/
port: number;

/**
* The workspace path for this server.
*/
workspacePath: string;

/**
* The project name for this server.
*/
project: string;
}

/**
Expand All @@ -70,18 +80,30 @@ export interface Devserver {
export class LocalDevserver implements Devserver {
readonly host: Host;
readonly port: number;
readonly project?: string;
readonly workspacePath: string;
readonly project: string;

private devserverProcess: ChildProcess | null = null;
private serverLogs: string[] = [];
private buildInProgress = false;
private latestBuildLogStartIndex?: number = undefined;
private latestBuildStatus: BuildStatus = 'unknown';

constructor({ host, port, project }: { host: Host; port: number; project?: string }) {
constructor({
host,
port,
workspacePath,
project,
}: {
host: Host;
port: number;
workspacePath: string;
project: string;
}) {
this.host = host;
this.project = project;
this.port = port;
this.workspacePath = workspacePath;
this.project = project;
}

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

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

this.devserverProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
this.devserverProcess = this.host.spawn('ng', args, {
stdio: 'pipe',
cwd: this.workspacePath,
});
this.devserverProcess.stdout?.on('data', (data) => {
this.addLog(data.toString());
});
Expand Down Expand Up @@ -142,3 +167,7 @@ export class LocalDevserver implements Devserver {
return this.buildInProgress;
}
}

export function getDevserverKey(workspacePath: string, projectName: string): string {
return `${workspacePath}:${projectName}`;
}
24 changes: 24 additions & 0 deletions packages/angular/cli/src/commands/mcp/shared-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { z } from 'zod';

export const workspaceAndProjectOptions = {
workspace: z
.string()
.optional()
.describe(
'The path to the workspace directory (containing angular.json). If not provided, uses the current directory.',
),
project: z
.string()
.optional()
.describe(
'Which project to target in a monorepo context. If not provided, targets the default project.',
),
};
14 changes: 10 additions & 4 deletions packages/angular/cli/src/commands/mcp/testing/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export interface MockContextOptions {
projects?: Record<string, workspaces.ProjectDefinition>;
}

/**
* Same as McpToolContext, just with guaranteed nonnull workspace.
*/
export interface MockMcpToolContext extends McpToolContext {
workspace: AngularWorkspace;
}

/**
* Creates a comprehensive mock for the McpToolContext, including a mock Host,
* an AngularWorkspace, and a ProjectDefinitionCollection. This simplifies testing
Expand All @@ -50,23 +57,22 @@ export interface MockContextOptions {
*/
export function createMockContext(options: MockContextOptions = {}): {
host: MockHost;
context: McpToolContext;
context: MockMcpToolContext;
projects: workspaces.ProjectDefinitionCollection;
workspace: AngularWorkspace;
} {
const host = options.host ?? createMockHost();
const projects = new workspaces.ProjectDefinitionCollection(options.projects);
const workspace = new AngularWorkspace({ projects, extensions: {} }, '/test/angular.json');

const context: McpToolContext = {
const context: MockMcpToolContext = {
server: {} as unknown as McpServer,
workspace,
logger: { warn: () => {} },
devservers: new Map<string, Devserver>(),
host,
};

return { host, context, projects, workspace };
return { host, context, projects };
}

/**
Expand Down
36 changes: 19 additions & 17 deletions packages/angular/cli/src/commands/mcp/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,21 @@
*/

import { z } from 'zod';
import { CommandError, type Host } from '../host';
import { createStructuredContentOutput, getCommandErrorLogs } from '../utils';
import { type McpToolDeclaration, declareTool } from './tool-registry';
import { workspaceAndProjectOptions } from '../shared-options';
import {
createStructuredContentOutput,
getCommandErrorLogs,
resolveWorkspaceAndProject,
} from '../utils';
import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry';

const DEFAULT_CONFIGURATION = 'development';

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

const buildToolInputSchema = z.object({
project: z
.string()
.optional()
.describe(
'Which project to build in a monorepo context. If not provided, builds the default project.',
),
...workspaceAndProjectOptions,
configuration: z
.string()
.optional()
Expand All @@ -39,20 +38,23 @@ const buildToolOutputSchema = z.object({

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

export async function runBuild(input: BuildToolInput, host: Host) {
export async function runBuild(input: BuildToolInput, context: McpToolContext) {
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
host: context.host,
workspacePathInput: input.workspace,
projectNameInput: input.project,
mcpWorkspace: context.workspace,
});

// Build "ng"'s command line.
const args = ['build'];
if (input.project) {
args.push(input.project);
}
args.push('-c', input.configuration ?? DEFAULT_CONFIGURATION);
const args = ['build', projectName, '-c', input.configuration ?? DEFAULT_CONFIGURATION];

let status: BuildStatus = 'success';
let logs: string[] = [];
let outputPath: string | undefined;

try {
logs = (await host.runCommand('ng', args)).logs;
logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs;
} catch (e) {
status = 'failure';
logs = getCommandErrorLogs(e);
Expand Down Expand Up @@ -101,5 +103,5 @@ Perform a one-off, non-watched build using "ng build". Use this tool whenever th
isLocalOnly: true,
inputSchema: buildToolInputSchema.shape,
outputSchema: buildToolOutputSchema.shape,
factory: (context) => (input) => runBuild(input, context.host),
factory: (context) => (input) => runBuild(input, context),
});
72 changes: 44 additions & 28 deletions packages/angular/cli/src/commands/mcp/tools/build_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,50 @@

import { CommandError } from '../host';
import type { MockHost } from '../testing/mock-host';
import { createMockHost } from '../testing/test-utils';
import {
MockMcpToolContext,
addProjectToWorkspace,
createMockContext,
} from '../testing/test-utils';
import { runBuild } from './build';

describe('Build Tool', () => {
let mockHost: MockHost;
let mockContext: MockMcpToolContext;

beforeEach(() => {
mockHost = createMockHost();
const mock = createMockContext();
mockHost = mock.host;
mockContext = mock.context;
addProjectToWorkspace(mock.projects, 'my-app');
});

it('should construct the command correctly with default configuration', async () => {
await runBuild({}, mockHost);
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'development']);
mockContext.workspace.extensions['defaultProject'] = 'my-app';
await runBuild({}, mockContext);
expect(mockHost.runCommand).toHaveBeenCalledWith(
'ng',
['build', 'my-app', '-c', 'development'],
{ cwd: '/test' },
);
});

it('should construct the command correctly with a specified project', async () => {
await runBuild({ project: 'another-app' }, mockHost);
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
'build',
'another-app',
'-c',
'development',
]);
addProjectToWorkspace(mockContext.workspace.projects, 'another-app');
await runBuild({ project: 'another-app' }, mockContext);
expect(mockHost.runCommand).toHaveBeenCalledWith(
'ng',
['build', 'another-app', '-c', 'development'],
{ cwd: '/test' },
);
});

it('should construct the command correctly for a custom configuration', async () => {
await runBuild({ configuration: 'myconfig' }, mockHost);
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'myconfig']);
mockContext.workspace.extensions['defaultProject'] = 'my-app';
await runBuild({ configuration: 'myconfig' }, mockContext);
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], {
cwd: '/test',
});
});

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

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

expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
'build',
'my-app',
'-c',
'development',
]);
expect(mockHost.runCommand).toHaveBeenCalledWith(
'ng',
['build', 'my-app', '-c', 'development'],
{ cwd: '/test' },
);
expect(structuredContent.status).toBe('success');
expect(structuredContent.logs).toEqual(buildLogs);
expect(structuredContent.path).toBe('dist/my-app');
});

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

const { structuredContent } = await runBuild(
{ project: 'my-failed-app', configuration: 'production' },
mockHost,
mockContext,
);

expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
'build',
'my-failed-app',
'-c',
'production',
]);
expect(mockHost.runCommand).toHaveBeenCalledWith(
'ng',
['build', 'my-failed-app', '-c', 'production'],
{ cwd: '/test' },
);
expect(structuredContent.status).toBe('failure');
expect(structuredContent.logs).toEqual([...buildLogs, 'Build failed']);
expect(structuredContent.path).toBeUndefined();
Expand All @@ -87,7 +102,8 @@ describe('Build Tool', () => {
const buildLogs = ["Some logs that don't match any output path."];
mockHost.runCommand.and.resolveTo({ logs: buildLogs });

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

expect(structuredContent.status).toBe('success');
expect(structuredContent.logs).toEqual(buildLogs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@
*/

import { z } from 'zod';
import { LocalDevserver } from '../../devserver';
import { createStructuredContentOutput, getDefaultProjectName } from '../../utils';
import { LocalDevserver, getDevserverKey } from '../../devserver';
import { workspaceAndProjectOptions } from '../../shared-options';
import { createStructuredContentOutput, resolveWorkspaceAndProject } from '../../utils';
import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry';

const devserverStartToolInputSchema = z.object({
project: z
.string()
.optional()
.describe(
'Which project to serve in a monorepo context. If not provided, serves the default project.',
),
...workspaceAndProjectOptions,
});

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

export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) {
const projectName = input.project ?? getDefaultProjectName(context);
const { workspacePath, projectName } = await resolveWorkspaceAndProject({
host: context.host,
workspacePathInput: input.workspace,
projectNameInput: input.project,
mcpWorkspace: context.workspace,
});

if (!projectName) {
return createStructuredContentOutput({
message: ['Project name not provided, and no default project found.'],
});
}
const key = getDevserverKey(workspacePath, projectName);

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

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

devserver = new LocalDevserver({ host: context.host, project: input.project, port });
devserver = new LocalDevserver({
host: context.host,
project: projectName,
port,
workspacePath,
});
devserver.start();

context.devservers.set(projectName, devserver);
context.devservers.set(key, devserver);

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