Skip to content

Commit ef8f4ef

Browse files
committed
feat(@schematics/angular): update ai-config to include Angular MCP server config
Update the `ai-config` schematic, which is activated during workspace creation, to enable Angular MCP server by default.
1 parent 617dd4e commit ef8f4ef

11 files changed

Lines changed: 380 additions & 162 deletions

File tree

packages/schematics/angular/BUILD.bazel

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,9 @@ genrule(
5050
srcs = [
5151
"//:node_modules/@angular/core/dir",
5252
],
53-
outs = ["ai-config/files/__rulesName__.template"],
53+
outs = ["ai-config/files/__bestPracticesName__.template"],
5454
cmd = """
55-
echo -e "<% if (frontmatter) { %><%= frontmatter %>\\n<% } %>" > $@
56-
cat "$(location //:node_modules/@angular/core/dir)/resources/best-practices.md" >> $@
55+
cp "$(location //:node_modules/@angular/core/dir)/resources/best-practices.md" $@
5756
""",
5857
)
5958

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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 {
10+
Rule,
11+
apply,
12+
applyTemplates,
13+
filter,
14+
forEach,
15+
mergeWith,
16+
move,
17+
noop,
18+
strings,
19+
url,
20+
} from '@angular-devkit/schematics';
21+
import { FileConfigurationHandlerOptions } from './types';
22+
23+
/**
24+
* Create or update a JSON MCP configuration file to include the Angular MCP server.
25+
*/
26+
export function addJsonMcpConfig(
27+
{ tree, context, fileInfo, tool }: FileConfigurationHandlerOptions,
28+
mcpServersProperty: string,
29+
): Rule {
30+
const { name, directory } = fileInfo;
31+
32+
return mergeWith(
33+
apply(url('./files'), [
34+
filter((path) => path.includes('__jsonConfigName__')),
35+
applyTemplates({
36+
...strings,
37+
jsonConfigName: name,
38+
mcpServersProperty,
39+
}),
40+
move(directory),
41+
forEach((file) => {
42+
if (!tree.exists(file.path)) {
43+
return file;
44+
}
45+
46+
const existingFileBuffer = tree.read(file.path);
47+
48+
// If we have an existing file, update the server property with
49+
// Angular MCP server configuration.
50+
if (existingFileBuffer) {
51+
// The JSON config file should be record-like.
52+
let existing: Record<string, unknown>;
53+
try {
54+
existing = JSON.parse(existingFileBuffer.toString());
55+
} catch {
56+
const path = `${directory}/${name}`;
57+
const toolName = strings.classify(tool);
58+
context.logger.warn(
59+
`Skipping Angular MCP server configuration for '${toolName}'.\n` +
60+
`Unable to modify '${path}'. ` +
61+
'Make sure that the file has a valid JSON syntax.\n',
62+
);
63+
64+
return null;
65+
}
66+
const existingServersProp = existing[mcpServersProperty];
67+
const templateServersProp = JSON.parse(file.content.toString())[mcpServersProperty];
68+
69+
// Note: If the Angular MCP server config already exists, we'll overwrite it.
70+
existing[mcpServersProperty] = existingServersProp
71+
? {
72+
...existingServersProp,
73+
...templateServersProp,
74+
}
75+
: templateServersProp;
76+
77+
tree.overwrite(file.path, JSON.stringify(existing, null, 2));
78+
79+
return null;
80+
}
81+
82+
return file;
83+
}),
84+
]),
85+
);
86+
}
87+
88+
/**
89+
* Create a TOML MCP configuration file to include the Angular MCP server.
90+
* If the file exists, the configuration is skipped.
91+
*/
92+
export function addTomlMcpConfig({
93+
tree,
94+
context,
95+
fileInfo,
96+
tool,
97+
}: FileConfigurationHandlerOptions): Rule {
98+
const { name, directory } = fileInfo;
99+
const path = `${directory}/${name}`;
100+
101+
if (tree.exists(path)) {
102+
const toolName = strings.classify(tool);
103+
// At this stage, we don't support TOML file modifications.
104+
context.logger.warn(
105+
`Skipping configuration file for '${toolName}' at '${path}' because it already exists.\n` +
106+
'Please add the configuration to the TOML file manually.\n',
107+
);
108+
109+
return noop();
110+
}
111+
112+
return mergeWith(
113+
apply(url('./files'), [
114+
filter((path) => path.includes('__tomlConfigName__')),
115+
applyTemplates({
116+
...strings,
117+
tomlConfigName: name,
118+
}),
119+
move(directory),
120+
]),
121+
);
122+
}
123+
124+
/**
125+
* Create an Angular best practices Markdown.
126+
* If the file exists, the configuration is skipped.
127+
*/
128+
export function addBestPracticesMarkdown({
129+
tree,
130+
context,
131+
fileInfo,
132+
tool,
133+
}: FileConfigurationHandlerOptions): Rule {
134+
const { name, directory } = fileInfo;
135+
const path = `${directory}/${name}`;
136+
137+
if (tree.exists(path)) {
138+
const toolName = strings.classify(tool);
139+
context.logger.warn(
140+
`Skipping configuration file for '${toolName}' at '${path}' because it already exists.\n` +
141+
'This is to prevent overwriting a potentially customized file. ' +
142+
'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' +
143+
'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.\n',
144+
);
145+
146+
return noop();
147+
}
148+
149+
return mergeWith(
150+
apply(url('./files'), [
151+
filter((path) => path.includes('__bestPracticesName__')),
152+
applyTemplates({
153+
...strings,
154+
bestPracticesName: name,
155+
}),
156+
move(directory),
157+
]),
158+
);
159+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
// it('should not overwrite an existing file', async () => {
10+
// const customContent = 'custom user content';
11+
// workspaceTree.create('.gemini/GEMINI.md', customContent);
12+
13+
// const messages: string[] = [];
14+
// const loggerSubscription = schematicRunner.logger.subscribe((x) => messages.push(x.message));
15+
16+
// try {
17+
// const tree = await runConfigSchematic([ConfigTool.Gemini]);
18+
19+
// expect(tree.readContent('.gemini/GEMINI.md')).toBe(customContent);
20+
// expect(messages).toContain(
21+
// `Skipping configuration file for 'Gemini' at '.gemini/GEMINI.md' because it already exists.\n` +
22+
// 'This is to prevent overwriting a potentially customized file. ' +
23+
// 'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' +
24+
// 'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.',
25+
// );
26+
// } finally {
27+
// loggerSubscription.unsubscribe();
28+
// }
29+
// });
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"<%= mcpServersProperty %>": {
3+
"angular-cli": {
4+
"command": "npx",
5+
"args": ["-y", "@angular/cli", "mcp"]
6+
}
7+
}
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[mcp_servers.angular-cli]
2+
command = "npx"
3+
args = ["-y", "@angular/cli", "mcp"]

packages/schematics/angular/ai-config/index.ts

Lines changed: 82 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,63 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {
10-
Rule,
11-
apply,
12-
applyTemplates,
13-
chain,
14-
mergeWith,
15-
move,
16-
noop,
17-
strings,
18-
url,
19-
} from '@angular-devkit/schematics';
9+
import { Rule, chain, noop, strings } from '@angular-devkit/schematics';
10+
import { addBestPracticesMarkdown, addJsonMcpConfig, addTomlMcpConfig } from './file_utils';
2011
import { Schema as ConfigOptions, Tool } from './schema';
12+
import { ContextFileInfo, ContextFileType, FileConfigurationHandlerOptions } from './types';
2113

22-
const AI_TOOLS: { [key in Exclude<Tool, Tool.None>]: ContextFileInfo } = {
23-
agents: {
24-
rulesName: 'AGENTS.md',
25-
directory: '.',
26-
},
27-
gemini: {
28-
rulesName: 'GEMINI.md',
29-
directory: '.gemini',
30-
},
31-
claude: {
32-
rulesName: 'CLAUDE.md',
33-
directory: '.claude',
34-
},
35-
copilot: {
36-
rulesName: 'copilot-instructions.md',
37-
directory: '.github',
38-
},
39-
windsurf: {
40-
rulesName: 'guidelines.md',
41-
directory: '.windsurf/rules',
42-
},
43-
jetbrains: {
44-
rulesName: 'guidelines.md',
45-
directory: '.junie',
46-
},
47-
// Cursor file has a front matter section.
48-
cursor: {
49-
rulesName: 'cursor.mdc',
50-
directory: '.cursor/rules',
51-
frontmatter: `---\ncontext: true\npriority: high\nscope: project\n---`,
52-
},
14+
const AGENTS_MD_CFG: ContextFileInfo = {
15+
type: ContextFileType.BestPracticesMd,
16+
name: 'AGENTS.md',
17+
directory: '.',
5318
};
5419

55-
interface ContextFileInfo {
56-
rulesName: string;
57-
directory: string;
58-
frontmatter?: string;
59-
}
20+
const AI_TOOLS: { [key in Exclude<Tool, Tool.None>]: ContextFileInfo[] } = {
21+
['claude-code']: [
22+
AGENTS_MD_CFG,
23+
{
24+
type: ContextFileType.McpConfig,
25+
name: '.mcp.json',
26+
directory: '.',
27+
},
28+
],
29+
cursor: [
30+
AGENTS_MD_CFG,
31+
{
32+
type: ContextFileType.McpConfig,
33+
name: 'mcp.json',
34+
directory: '.cursor',
35+
},
36+
],
37+
['gemini-cli']: [
38+
{
39+
type: ContextFileType.BestPracticesMd,
40+
name: 'GEMINI.md',
41+
directory: '.gemini',
42+
},
43+
{
44+
type: ContextFileType.McpConfig,
45+
name: 'settings.json',
46+
directory: '.gemini',
47+
},
48+
],
49+
['open-ai-codex']: [
50+
AGENTS_MD_CFG,
51+
{
52+
type: ContextFileType.McpConfig,
53+
name: 'config.toml',
54+
directory: '.codex',
55+
},
56+
],
57+
vscode: [
58+
AGENTS_MD_CFG,
59+
{
60+
type: ContextFileType.McpConfig,
61+
name: 'mcp.json',
62+
directory: '.vscode',
63+
},
64+
],
65+
};
6066

6167
export default function ({ tool }: ConfigOptions): Rule {
6268
return (tree, context) => {
@@ -66,33 +72,36 @@ export default function ({ tool }: ConfigOptions): Rule {
6672

6773
const rules = tool
6874
.filter((tool) => tool !== Tool.None)
69-
.map((selectedTool) => {
70-
const { rulesName, directory, frontmatter } = AI_TOOLS[selectedTool];
71-
const path = `${directory}/${rulesName}`;
72-
73-
if (tree.exists(path)) {
74-
const toolName = strings.classify(selectedTool);
75-
context.logger.warn(
76-
`Skipping configuration file for '${toolName}' at '${path}' because it already exists.\n` +
77-
'This is to prevent overwriting a potentially customized file. ' +
78-
'If you want to regenerate it with Angular recommended defaults, please delete the existing file and re-run the command.\n' +
79-
'You can review the latest recommendations at https://angular.dev/ai/develop-with-ai.',
80-
);
81-
82-
return noop();
83-
}
75+
.flatMap((selectedTool) =>
76+
AI_TOOLS[selectedTool].map((fileInfo) => {
77+
const fileCfgOpts: FileConfigurationHandlerOptions = {
78+
tree,
79+
context,
80+
fileInfo,
81+
tool: selectedTool,
82+
};
8483

85-
return mergeWith(
86-
apply(url('./files'), [
87-
applyTemplates({
88-
...strings,
89-
rulesName,
90-
frontmatter,
91-
}),
92-
move(directory),
93-
]),
94-
);
95-
});
84+
switch (fileInfo.type) {
85+
case ContextFileType.BestPracticesMd:
86+
return addBestPracticesMarkdown(fileCfgOpts);
87+
case ContextFileType.McpConfig:
88+
switch (selectedTool) {
89+
case Tool.ClaudeCode:
90+
case Tool.Cursor:
91+
case Tool.GeminiCli:
92+
return addJsonMcpConfig(fileCfgOpts, 'mcpServers');
93+
case Tool.OpenAiCodex:
94+
return addTomlMcpConfig(fileCfgOpts);
95+
case Tool.Vscode:
96+
return addJsonMcpConfig(fileCfgOpts, 'servers');
97+
default:
98+
throw new Error(
99+
`Unsupported '${strings.classify(selectedTool)}' MCP server configuraiton.`,
100+
);
101+
}
102+
}
103+
}),
104+
);
96105

97106
return chain(rules);
98107
};

0 commit comments

Comments
 (0)