Skip to content

Commit 857cecf

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 857cecf

6 files changed

Lines changed: 391 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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
const BUILT_IN_COMMANDS = new Set([
15+
'build',
16+
'test',
17+
'e2e',
18+
'serve',
19+
'deploy',
20+
'extract-i18n',
21+
'lint',
22+
]);
23+
24+
export class GenericTargetStrategy implements TargetStrategy {
25+
canHandle(target: string, builder?: string): boolean {
26+
return true; // Universal fallback strategy
27+
}
28+
29+
async execute(
30+
input: StrategyExecutionContext,
31+
context: McpToolContext,
32+
): Promise<RunTargetOutput> {
33+
if (input.target === 'serve' || input.options?.['watch'] === true) {
34+
throw new Error(
35+
`Watch mode execution (serve target or watch option) is not yet supported by 'run_target'. ` +
36+
`Please use the legacy 'devserver.start' / 'devserver.wait_for_build' tools instead.`,
37+
);
38+
}
39+
40+
const args: string[] = [];
41+
if (BUILT_IN_COMMANDS.has(input.target)) {
42+
args.push(input.target, input.projectName);
43+
} else {
44+
args.push('run', `${input.projectName}:${input.target}`);
45+
}
46+
47+
if (input.configuration) {
48+
args.push('-c', input.configuration);
49+
}
50+
51+
let options = input.options;
52+
if (input.target === 'test') {
53+
options = {
54+
...options,
55+
watch: false,
56+
};
57+
}
58+
59+
if (options) {
60+
for (const [key, value] of Object.entries(options)) {
61+
if (!/^[a-zA-Z0-9-_]+$/.test(key)) {
62+
throw new Error(
63+
`Invalid option key: '${key}'. Option keys must be alphanumeric, hyphens, or underscores.`,
64+
);
65+
}
66+
67+
if (typeof value === 'boolean') {
68+
args.push(value ? `--${key}` : `--no-${key}`);
69+
} else if (Array.isArray(value)) {
70+
for (const item of value) {
71+
args.push(`--${key}=${item}`);
72+
}
73+
} else if (value !== null && value !== undefined) {
74+
args.push(`--${key}=${value}`);
75+
}
76+
}
77+
}
78+
79+
let status: 'success' | 'failure' = 'success';
80+
let logs: string[];
81+
82+
try {
83+
const result = await context.host.executeNgCommand(args, { cwd: input.workspacePath });
84+
logs = result.logs;
85+
} catch (e) {
86+
status = 'failure';
87+
logs = getCommandErrorLogs(e);
88+
}
89+
90+
return { status, logs };
91+
}
92+
}
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: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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 route custom targets via ng run command syntax', async () => {
53+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
54+
await runTarget({ target: 'storybook', configuration: 'docs' }, mockContext);
55+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
56+
['run', 'my-app:storybook', '-c', 'docs'],
57+
{ cwd: '/test' },
58+
);
59+
});
60+
61+
it('should map boolean options correctly to CLI flags', async () => {
62+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
63+
await runTarget({ target: 'lint', options: { fix: true, quiet: false } }, mockContext);
64+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
65+
['lint', 'my-app', '--fix', '--no-quiet'],
66+
{ cwd: '/test' },
67+
);
68+
});
69+
70+
it('should map string and number options correctly to CLI flags and auto-inject no-watch', async () => {
71+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
72+
await runTarget(
73+
{ target: 'test', options: { browsers: 'ChromeHeadless', timeout: 5000 } },
74+
mockContext,
75+
);
76+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
77+
['test', 'my-app', '--browsers=ChromeHeadless', '--timeout=5000', '--no-watch'],
78+
{ cwd: '/test' },
79+
);
80+
});
81+
82+
it('should map array options correctly as multiple occurrences of the flag', async () => {
83+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
84+
await runTarget({ target: 'lint', options: { include: ['a', 'b'] } }, mockContext);
85+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(
86+
['lint', 'my-app', '--include=a', '--include=b'],
87+
{ cwd: '/test' },
88+
);
89+
});
90+
91+
it('should automatically inject no-watch for test target even if no options provided', async () => {
92+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
93+
await runTarget({ target: 'test' }, mockContext);
94+
expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['test', 'my-app', '--no-watch'], {
95+
cwd: '/test',
96+
});
97+
});
98+
99+
it('should throw an error if option key is malformed (contains whitespace/special chars)', async () => {
100+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
101+
await expectAsync(
102+
runTarget({ target: 'lint', options: { 'fix --danger': true } }, mockContext),
103+
).toBeRejectedWithError(/Invalid option key: 'fix --danger'/);
104+
});
105+
106+
it('should handle a successful execution and return logs', async () => {
107+
const executionLogs = ['Linting complete', 'All rules passed!'];
108+
mockHost.executeNgCommand.and.resolveTo({
109+
logs: executionLogs,
110+
});
111+
112+
const { structuredContent } = await runTarget(
113+
{ project: 'my-app', target: 'lint' },
114+
mockContext,
115+
);
116+
117+
expect(structuredContent.status).toBe('success');
118+
expect(structuredContent.logs).toEqual(executionLogs);
119+
});
120+
121+
it('should handle a failed execution and capture command errors', async () => {
122+
const executionLogs = ['Error: Rule violation found.'];
123+
const error = new CommandError('Lint failed', executionLogs, 1);
124+
mockHost.executeNgCommand.and.rejectWith(error);
125+
126+
const { structuredContent } = await runTarget(
127+
{ project: 'my-app', target: 'lint' },
128+
mockContext,
129+
);
130+
131+
expect(structuredContent.status).toBe('failure');
132+
expect(structuredContent.logs).toEqual([...executionLogs, 'Lint failed']);
133+
});
134+
135+
it('should throw an error if attempting to run the serve target', async () => {
136+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
137+
await expectAsync(runTarget({ target: 'serve' }, mockContext)).toBeRejectedWithError(
138+
/Watch mode execution.*is not yet supported/,
139+
);
140+
});
141+
142+
it('should throw an error if attempting to run a target with watch option true', async () => {
143+
mockContext.workspace.extensions['defaultProject'] = 'my-app';
144+
await expectAsync(
145+
runTarget({ target: 'build', options: { watch: true } }, mockContext),
146+
).toBeRejectedWithError(/Watch mode execution.*is not yet supported/);
147+
});
148+
});
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+
}

0 commit comments

Comments
 (0)