Skip to content

Commit aa51411

Browse files
committed
refactor(@angular/cli): implement experimental unified run_target facade and strategy dispatcher
Introduce the unified `run_target` MCP tool and the underlying builder strategy dispatcher in a dedicated `tools/run-target` subdirectory. This architecture leverages the strategy pattern to delegate Angular CLI target executions (build, test, lint, e2e) to specialized internal strategies, defaulting to a universal `GenericTargetStrategy` fallback.
1 parent dfa82ec commit aa51411

6 files changed

Lines changed: 357 additions & 1 deletion

File tree

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { DOC_SEARCH_TOOL } from './tools/doc-search';
2525
import { E2E_TOOL } from './tools/e2e';
2626
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
2727
import { LIST_PROJECTS_TOOL } from './tools/projects';
28+
import { RUN_TARGET_TOOL } from './tools/run-target/run-target';
2829
import { TEST_TOOL } from './tools/test';
2930
import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
3031

@@ -49,7 +50,13 @@ const STABLE_TOOLS = [
4950
* The set of tools that are available but not enabled by default.
5051
* These tools are considered experimental and may have limitations.
5152
*/
52-
export const EXPERIMENTAL_TOOLS = [BUILD_TOOL, E2E_TOOL, TEST_TOOL, ...DEVSERVER_TOOLS] as const;
53+
export const EXPERIMENTAL_TOOLS = [
54+
BUILD_TOOL,
55+
E2E_TOOL,
56+
TEST_TOOL,
57+
RUN_TARGET_TOOL,
58+
...DEVSERVER_TOOLS,
59+
] as const;
5360

5461
/**
5562
* Experimental tools that are grouped together under a single name.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 { getCommandErrorLogs } from '../../utils';
10+
import type { McpToolContext } from '../tool-registry';
11+
import type { TargetStrategy } from './strategy';
12+
import type { RunTargetOutput, StrategyExecutionContext } from './types';
13+
14+
export class GenericTargetStrategy implements TargetStrategy {
15+
canHandle(target: string, builder?: string): boolean {
16+
return true; // Universal fallback strategy
17+
}
18+
19+
async execute(
20+
input: StrategyExecutionContext,
21+
context: McpToolContext,
22+
): Promise<RunTargetOutput> {
23+
if (input.target === 'serve' || input.options?.['watch'] === true) {
24+
throw new Error(
25+
`Watch mode execution (serve target or watch option) is not yet supported by 'run_target'. ` +
26+
`Please use the legacy 'devserver.start' / 'devserver.wait_for_build' tools instead.`,
27+
);
28+
}
29+
30+
const args = [input.target, input.projectName];
31+
if (input.configuration) {
32+
args.push('-c', input.configuration);
33+
}
34+
35+
let options = input.options;
36+
if (input.target === 'test') {
37+
options = {
38+
...options,
39+
watch: false,
40+
};
41+
}
42+
43+
if (options) {
44+
for (const [key, value] of Object.entries(options)) {
45+
if (!/^[a-zA-Z0-9-_]+$/.test(key)) {
46+
throw new Error(
47+
`Invalid option key: '${key}'. Option keys must be alphanumeric, hyphens, or underscores.`,
48+
);
49+
}
50+
51+
if (typeof value === 'boolean') {
52+
if (value) {
53+
args.push(`--${key}`);
54+
} else {
55+
args.push(`--no-${key}`);
56+
}
57+
} else {
58+
args.push(`--${key}=${value}`);
59+
}
60+
}
61+
}
62+
63+
let status: 'success' | 'failure' = 'success';
64+
let logs: string[];
65+
66+
try {
67+
const result = await context.host.executeNgCommand(args, { cwd: input.workspacePath });
68+
logs = result.logs;
69+
} catch (e) {
70+
status = 'failure';
71+
logs = getCommandErrorLogs(e);
72+
}
73+
74+
return { status, logs };
75+
}
76+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 { createStructuredContentOutput } from '../../utils';
10+
import { resolveWorkspaceAndProject } from '../../workspace-utils';
11+
import { type McpToolContext, declareTool } from '../tool-registry';
12+
import { GenericTargetStrategy } from './generic-target-strategy';
13+
import type { TargetStrategy } from './strategy';
14+
import { type RunTargetInput, runTargetInputSchema, runTargetOutputSchema } from './types';
15+
16+
const FALLBACK_STRATEGY = new GenericTargetStrategy();
17+
const STRATEGIES: TargetStrategy[] = [];
18+
19+
export async function runTarget(input: RunTargetInput, context: McpToolContext) {
20+
const { workspace, workspacePath, projectName } = await resolveWorkspaceAndProject({
21+
host: context.host,
22+
server: context.server,
23+
workspacePathInput: input.workspace,
24+
projectNameInput: input.project,
25+
mcpWorkspace: context.workspace,
26+
});
27+
28+
const targetDefinition = workspace.projects.get(projectName)?.targets.get(input.target);
29+
const builder = targetDefinition?.builder;
30+
31+
const strategy = STRATEGIES.find((s) => s.canHandle(input.target, builder)) ?? FALLBACK_STRATEGY;
32+
33+
const result = await strategy.execute(
34+
{
35+
workspacePath,
36+
projectName,
37+
target: input.target,
38+
configuration: input.configuration,
39+
options: input.options,
40+
},
41+
context,
42+
);
43+
44+
return createStructuredContentOutput(result);
45+
}
46+
47+
export const RUN_TARGET_TOOL = declareTool({
48+
name: 'run_target',
49+
title: 'Run Project Target',
50+
description: `
51+
<Purpose>
52+
Executes a configured target (such as build, test, lint, e2e) for an Angular project.
53+
This is the single, unified interface for executing all project tasks natively.
54+
</Purpose>
55+
<Use Cases>
56+
* Building an application or library.
57+
* Running unit tests, E2E tests, or linters.
58+
* Deploying or running custom workspace targets discovered via 'list_projects'.
59+
</Use Cases>
60+
<Operational Notes>
61+
* Mandatory Discovery: You MUST discover available project targets by calling 'list_projects' first.
62+
* Watch mode (serve target or watch options) is NOT yet supported in this version of run_target.
63+
You MUST use the legacy 'devserver.*' tools for background server lifecycles.
64+
</Operational Notes>`,
65+
isReadOnly: false,
66+
isLocalOnly: true,
67+
inputSchema: runTargetInputSchema.shape,
68+
outputSchema: runTargetOutputSchema.shape,
69+
factory: (context) => (input) => runTarget(input, context),
70+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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 { CommandError } from '../../host';
10+
import type { MockHost } from '../../testing/mock-host';
11+
import {
12+
type MockMcpToolContext,
13+
addProjectToWorkspace,
14+
createMockContext,
15+
} from '../../testing/test-utils';
16+
import { runTarget } from './run-target';
17+
18+
describe('Run Target Tool', () => {
19+
let mockHost: MockHost;
20+
let mockContext: MockMcpToolContext;
21+
22+
beforeEach(() => {
23+
const mock = createMockContext();
24+
mockHost = mock.host;
25+
mockContext = mock.context;
26+
addProjectToWorkspace(mock.projects, 'my-app');
27+
});
28+
29+
it('should construct the command correctly with target and default project', async () => {
30+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
31+
await runTarget({ target: 'build' }, mockContext);
32+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['build', 'my-app'], { cwd: '/test' });
33+
});
34+
35+
it('should construct the command correctly with a specified project', async () => {
36+
addProjectToWorkspace(mockContext.workspace.projects, 'my-lib');
37+
await runTarget({ project: 'my-lib', target: 'lint' }, mockContext);
38+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['lint', 'my-lib'], { cwd: '/test' });
39+
});
40+
41+
it('should construct the command correctly with configuration', async () => {
42+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
43+
await runTarget({ target: 'build', configuration: 'production' }, mockContext);
44+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
45+
['build', 'my-app', '-c', 'production'],
46+
{
47+
cwd: '/test',
48+
},
49+
);
50+
});
51+
52+
it('should map boolean options correctly to CLI flags', async () => {
53+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
54+
await runTarget({ target: 'lint', options: { fix: true, quiet: false } }, mockContext);
55+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
56+
['lint', 'my-app', '--fix', '--no-quiet'],
57+
{ cwd: '/test' },
58+
);
59+
});
60+
61+
it('should map string and number options correctly to CLI flags and auto-inject no-watch', async () => {
62+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
63+
await runTarget(
64+
{ target: 'test', options: { browsers: 'ChromeHeadless', timeout: 5000 } },
65+
mockContext,
66+
);
67+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
68+
['test', 'my-app', '--browsers=ChromeHeadless', '--timeout=5000', '--no-watch'],
69+
{ cwd: '/test' },
70+
);
71+
});
72+
73+
it('should automatically inject no-watch for test target even if no options provided', async () => {
74+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
75+
await runTarget({ target: 'test' }, mockContext);
76+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['test', 'my-app', '--no-watch'], {
77+
cwd: '/test',
78+
});
79+
});
80+
81+
it('should throw an error if option key is malformed (contains whitespace/special chars)', async () => {
82+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
83+
await expectAsync(
84+
runTarget({ target: 'lint', options: { 'fix --danger': true } }, mockContext),
85+
).toBeRejectedWithError(/Invalid option key: 'fix --danger'/);
86+
});
87+
88+
it('should handle a successful execution and return logs', async () => {
89+
const executionLogs = ['Linting complete', 'All rules passed!'];
90+
mockHost.executeNgCommand.and.resolveTo({
91+
logs: executionLogs,
92+
});
93+
94+
const { structuredContent } = await runTarget(
95+
{ project: 'my-app', target: 'lint' },
96+
mockContext,
97+
);
98+
99+
expect(structuredContent.status).toBe('success');
100+
expect(structuredContent.logs).toEqual(executionLogs);
101+
});
102+
103+
it('should handle a failed execution and capture command errors', async () => {
104+
const executionLogs = ['Error: Rule violation found.'];
105+
const error = new CommandError('Lint failed', executionLogs, 1);
106+
mockHost.executeNgCommand.and.rejectWith(error);
107+
108+
const { structuredContent } = await runTarget(
109+
{ project: 'my-app', target: 'lint' },
110+
mockContext,
111+
);
112+
113+
expect(structuredContent.status).toBe('failure');
114+
expect(structuredContent.logs).toEqual([...executionLogs, 'Lint failed']);
115+
});
116+
117+
it('should throw an error if attempting to run the serve target', async () => {
118+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
119+
await expectAsync(runTarget({ target: 'serve' }, mockContext)).toBeRejectedWithError(
120+
/Watch mode execution.*is not yet supported/,
121+
);
122+
});
123+
124+
it('should throw an error if attempting to run a target with watch option true', async () => {
125+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
126+
await expectAsync(
127+
runTarget({ target: 'build', options: { watch: true } }, mockContext),
128+
).toBeRejectedWithError(/Watch mode execution.*is not yet supported/);
129+
});
130+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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 type { McpToolContext } from '../tool-registry';
10+
import type { RunTargetOutput, StrategyExecutionContext } from './types';
11+
12+
export interface TargetStrategy {
13+
/** Whether this strategy is responsible for handling the given target/builder */
14+
canHandle(target: string, builder?: string): boolean;
15+
16+
/** Executes the target using this strategy */
17+
execute(input: StrategyExecutionContext, context: McpToolContext): Promise<RunTargetOutput>;
18+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
import { workspaceAndProjectOptions } from '../../shared-options';
11+
12+
export const optionValueSchema = z.union([
13+
z.string(),
14+
z.number(),
15+
z.boolean(),
16+
z.array(z.union([z.string(), z.number()])),
17+
]);
18+
19+
export type OptionValue = z.infer<typeof optionValueSchema>;
20+
21+
export const runTargetInputSchema = z.object({
22+
...workspaceAndProjectOptions,
23+
target: z
24+
.string()
25+
.describe('The project target to execute (e.g., "build", "test", "lint", "e2e", "deploy").'),
26+
configuration: z
27+
.string()
28+
.optional()
29+
.describe('Target configuration (e.g., "development", "production").'),
30+
options: z
31+
.record(z.string(), optionValueSchema)
32+
.optional()
33+
.describe('Optional key-value options to override the configured target options.'),
34+
});
35+
36+
export type RunTargetInput = z.infer<typeof runTargetInputSchema>;
37+
38+
export const runTargetOutputSchema = z.object({
39+
status: z.enum(['success', 'failure']).describe('Execution status.'),
40+
logs: z.array(z.string()).describe('Clean, line-buffered output logs from execution.'),
41+
extensions: z
42+
.record(z.string(), z.unknown())
43+
.optional()
44+
.describe('Specialized metadata populated by specific target strategies.'),
45+
});
46+
47+
export type RunTargetOutput = z.infer<typeof runTargetOutputSchema>;
48+
49+
export interface StrategyExecutionContext {
50+
workspacePath: string;
51+
projectName: string;
52+
target: string;
53+
configuration?: string;
54+
options?: Record<string, OptionValue>;
55+
}

0 commit comments

Comments
 (0)