Skip to content

Commit e5ccaa7

Browse files
refactor(ai-config): shared MCP server config logic w/ jsonc-parser (#1648)
Co-authored-by: damyanpetev <damyanpetev@users.noreply.github.com>
1 parent 830d1b8 commit e5ccaa7

File tree

7 files changed

+116
-121
lines changed

7 files changed

+116
-121
lines changed

packages/cli/lib/commands/ai-config.ts

Lines changed: 5 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,14 @@
1-
import { copyAISkillsToProject, FsFileSystem, GoogleAnalytics, IFileSystem, Util } from "@igniteui/cli-core";
1+
import { addMcpServers, copyAISkillsToProject, GoogleAnalytics, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core";
22
import { ArgumentsCamelCase, CommandModule } from "yargs";
3-
import * as path from "path";
43

5-
const IGNITEUI_SERVER_KEY = "igniteui-cli";
6-
const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming";
7-
8-
const igniteuiServer = {
9-
command: "npx",
10-
args: ["-y", "igniteui-cli@next", "mcp"]
11-
};
12-
13-
const igniteuiThemingServer = {
14-
command: "npx",
15-
args: ["-y", "igniteui-theming", "igniteui-theming-mcp"]
16-
};
17-
18-
interface McpServerEntry {
19-
command: string;
20-
args: string[];
21-
}
22-
23-
interface VsCodeMcpConfig {
24-
servers: Record<string, McpServerEntry>;
25-
}
26-
27-
function getConfigPath(): string {
28-
return path.join(process.cwd(), ".vscode", "mcp.json");
29-
}
30-
31-
function readJson<T>(filePath: string, fallback: T, fileSystem: IFileSystem): T {
32-
try {
33-
return JSON.parse(fileSystem.readFile(filePath)) as T;
34-
} catch {
35-
return fallback;
36-
}
37-
}
38-
39-
function writeJson(filePath: string, data: unknown, fileSystem: IFileSystem): void {
40-
fileSystem.writeFile(filePath, JSON.stringify(data, null, 2) + "\n");
41-
}
42-
43-
export function configureMCP(fileSystem: IFileSystem = new FsFileSystem()): void {
44-
const configPath = getConfigPath();
45-
const config = readJson<VsCodeMcpConfig>(configPath, { servers: {} }, fileSystem);
46-
config.servers = config.servers || {};
47-
48-
let modified = false;
49-
if (!config.servers[IGNITEUI_SERVER_KEY]) {
50-
config.servers[IGNITEUI_SERVER_KEY] = igniteuiServer;
51-
modified = true;
52-
}
53-
if (!config.servers[IGNITEUI_THEMING_SERVER_KEY]) {
54-
config.servers[IGNITEUI_THEMING_SERVER_KEY] = igniteuiThemingServer;
55-
modified = true;
56-
}
4+
export function configureMCP(): void {
5+
const modified = addMcpServers(VS_CODE_MCP_PATH);
576

587
if (!modified) {
59-
Util.log(` Ignite UI MCP servers already configured in ${configPath}`);
8+
Util.log(` Ignite UI MCP servers already configured in ${VS_CODE_MCP_PATH}`);
609
return;
6110
}
62-
writeJson(configPath, config, fileSystem);
63-
Util.log(Util.greenCheck() + ` MCP servers configured in ${configPath}`);
11+
Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`);
6412
}
6513

6614
export function configureSkills(): void {

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@inquirer/prompts": "^7.9.0",
1616
"chalk": "^2.3.2",
1717
"glob": "^11.0.0",
18+
"jsonc-parser": "3.3.1",
1819
"through2": "^2.0.3",
1920
"typescript": "~5.5.4"
2021
},

packages/core/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './ai-skills';
22
export * from './detect-framework';
33
export * from './GoogleAnalytics';
4+
export * from './mcp-config';
45
export * from './Util';
56
export * from './ProjectConfig';
67
export * from './Schematics';

packages/core/util/mcp-config.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { FS_TOKEN, IFileSystem } from "../types/FileSystem";
2+
import * as jsonc from "jsonc-parser";
3+
import { App } from "./App";
4+
5+
export interface McpServerEntry {
6+
command: string;
7+
args: string[];
8+
}
9+
10+
const IGNITEUI_MCP_SERVERS: Record<string, McpServerEntry> = {
11+
"igniteui-cli": {
12+
command: "npx",
13+
args: ["-y", "igniteui-cli@next", "mcp"]
14+
},
15+
"igniteui-theming": {
16+
command: "npx",
17+
args: ["-y", "igniteui-theming", "igniteui-theming-mcp"]
18+
}
19+
};
20+
21+
export const VS_CODE_MCP_PATH = ".vscode/mcp.json";
22+
23+
/**
24+
* Reads .vscode/mcp.json, ensures all IgniteUI MCP servers are present,
25+
* optionally adds additional servers. Creates the file if it doesn't exist.
26+
* @param additionalServers optional extra servers to include alongside the built-in ones
27+
* @returns whether the file was modified
28+
*/
29+
export function addMcpServers(
30+
mcpFilePath: string,
31+
additionalServers?: Record<string, McpServerEntry>
32+
): boolean {
33+
const fileSystem = App.container.get<IFileSystem>(FS_TOKEN);
34+
const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS };
35+
36+
let existingContent: string | undefined;
37+
try {
38+
existingContent = fileSystem.readFile(mcpFilePath);
39+
} catch {
40+
existingContent = undefined;
41+
}
42+
43+
if (!existingContent) {
44+
if (Object.keys(servers).length === 0) {
45+
return false;
46+
}
47+
fileSystem.writeFile(mcpFilePath, JSON.stringify({ servers }, null, 2) + "\n");
48+
return true;
49+
}
50+
51+
const parsed = jsonc.parse(existingContent);
52+
const existing = parsed.servers ?? {};
53+
const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true };
54+
55+
let text = existingContent;
56+
let modified = false;
57+
58+
for (const [key, value] of Object.entries(servers)) {
59+
if (!existing[key]) {
60+
const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions });
61+
text = jsonc.applyEdits(text, edits);
62+
modified = true;
63+
}
64+
}
65+
66+
if (modified) {
67+
fileSystem.writeFile(mcpFilePath, text);
68+
}
69+
70+
return modified;
71+
}

packages/ng-schematics/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
"@igniteui/angular-templates": "~21.1.1500-rc.2",
2424
"@igniteui/cli-core": "~15.0.0-rc.2",
2525
"@schematics/angular": "^21.0.0",
26-
"jsonc-parser": "3.3.1",
2726
"minimatch": "^10.0.1",
2827
"rxjs": "~7.8.1"
2928
},

packages/ng-schematics/src/cli-config/index.ts

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import * as ts from "typescript";
22
import { DependencyNotFoundException } from "@angular-devkit/core";
33
import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics";
4-
import * as jsonc from "jsonc-parser";
5-
import { addClassToBody, App, copyAISkillsToProject, FormatSettings, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core";
4+
import { addClassToBody, addMcpServers, App, copyAISkillsToProject, FormatSettings, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils, VS_CODE_MCP_PATH } from "@igniteui/cli-core";
65
import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates";
76
import { createCliConfig } from "../utils/cli-config";
87
import { setVirtual } from "../utils/NgFileSystem";
@@ -119,72 +118,47 @@ function importStyles(): Rule {
119118
};
120119
}
121120

122-
export function addAIConfig(): Rule {
121+
/** Initialize the App container with TemplateManager and virtual FS */
122+
function appInit(tree: Tree) {
123+
App.initialize("angular-cli");
124+
// must be initialized with physical fs first:
125+
App.container.set(TEMPLATE_MANAGER, new SchematicsTemplateManager());
126+
setVirtual(tree);
127+
}
128+
129+
function aiConfig({ init } = { init: true }): Rule {
123130
return (tree: Tree) => {
131+
if (init) {
132+
appInit(tree);
133+
}
124134
copyAISkillsToProject();
125135

126-
const mcpFilePath = "/.vscode/mcp.json";
127-
const angularCliServer = {
128-
command: "npx",
129-
args: ["-y", "@angular/cli", "mcp"]
130-
};
131-
const igniteuiServer = {
132-
command: "npx",
133-
args: ["-y", "igniteui-cli@next", "mcp"]
134-
};
135-
const igniteuiThemingServer = {
136-
command: "npx",
137-
args: ["-y", "igniteui-theming", "igniteui-theming-mcp"]
136+
const angularCliServer: Record<string, McpServerEntry> = {
137+
"angular-cli": {
138+
command: "npx",
139+
args: ["-y", "@angular/cli", "mcp"]
140+
}
138141
};
139142

140-
if (tree.exists(mcpFilePath)) {
141-
let text = tree.read(mcpFilePath)!.toString();
142-
const content = jsonc.parse(text);
143-
const servers = content.servers ?? {};
144-
const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true };
145-
const newServers: Record<string, object> = {};
146-
if (!servers["angular-cli"]) {
147-
newServers["angular-cli"] = angularCliServer;
148-
}
149-
if (!servers["igniteui-cli"]) {
150-
newServers["igniteui-cli"] = igniteuiServer;
151-
}
152-
if (!servers["igniteui-theming"]) {
153-
newServers["igniteui-theming"] = igniteuiThemingServer;
154-
}
155-
for (const [key, value] of Object.entries(newServers)) {
156-
const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions });
157-
text = jsonc.applyEdits(text, edits);
158-
}
159-
if (Object.keys(newServers).length > 0) {
160-
tree.overwrite(mcpFilePath, text);
161-
}
162-
} else {
163-
const mcpConfig = {
164-
servers: {
165-
"angular-cli": angularCliServer,
166-
"igniteui-cli": igniteuiServer,
167-
"igniteui-theming": igniteuiThemingServer
168-
}
169-
};
170-
tree.create(mcpFilePath, JSON.stringify(mcpConfig, null, 2));
171-
}
143+
addMcpServers(VS_CODE_MCP_PATH, angularCliServer);
172144
};
173145
}
174146

147+
/** Standalone `ai-config` schematic entry */
148+
export function addAIConfig(): Rule {
149+
return aiConfig();
150+
}
151+
175152
export default function (): Rule {
176153
return (tree: Tree) => {
177-
App.initialize("angular-cli");
178-
// must be initialized with physical fs first:
179-
App.container.set(TEMPLATE_MANAGER, new SchematicsTemplateManager());
180-
setVirtual(tree);
154+
appInit(tree);
181155
return chain([
182156
importStyles(),
183157
addTypographyToProj(),
184158
importBrowserAnimations(),
185159
createCliConfig(),
186160
displayVersionMismatch(),
187-
addAIConfig()
161+
aiConfig({ init: false })
188162
]);
189163
};
190164
}

spec/unit/ai-config-spec.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ describe("Unit - ai-config command", () => {
4040
describe("configureMCP", () => {
4141
it("creates .vscode/mcp.json with both servers when file does not exist", () => {
4242
const mockFs = createMockFs();
43+
App.container.set(FS_TOKEN, mockFs);
4344

44-
configureMCP(mockFs);
45+
configureMCP();
4546

4647
expect(mockFs.writeFile).toHaveBeenCalled();
4748
const config = writtenConfig(mockFs);
@@ -51,8 +52,9 @@ describe("Unit - ai-config command", () => {
5152

5253
it("adds both servers when file exists but servers object is empty", () => {
5354
const mockFs = createMockFs(JSON.stringify({ servers: {} }));
55+
App.container.set(FS_TOKEN, mockFs);
5456

55-
configureMCP(mockFs);
57+
configureMCP();
5658

5759
expect(mockFs.writeFile).toHaveBeenCalled();
5860
const config = writtenConfig(mockFs);
@@ -64,8 +66,9 @@ describe("Unit - ai-config command", () => {
6466
const mockFs = createMockFs(JSON.stringify({
6567
servers: { [IGNITEUI_SERVER_KEY]: igniteuiServer }
6668
}));
69+
App.container.set(FS_TOKEN, mockFs);
6770

68-
configureMCP(mockFs);
71+
configureMCP();
6972

7073
expect(mockFs.writeFile).toHaveBeenCalled();
7174
const config = writtenConfig(mockFs);
@@ -77,8 +80,9 @@ describe("Unit - ai-config command", () => {
7780
const mockFs = createMockFs(JSON.stringify({
7881
servers: { [IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer }
7982
}));
83+
App.container.set(FS_TOKEN, mockFs);
8084

81-
configureMCP(mockFs);
85+
configureMCP();
8286

8387
expect(mockFs.writeFile).toHaveBeenCalled();
8488
const config = writtenConfig(mockFs);
@@ -93,8 +97,9 @@ describe("Unit - ai-config command", () => {
9397
[IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer
9498
}
9599
}));
100+
App.container.set(FS_TOKEN, mockFs);
96101

97-
configureMCP(mockFs);
102+
configureMCP();
98103

99104
expect(mockFs.writeFile).not.toHaveBeenCalled();
100105
expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already configured"));
@@ -105,8 +110,9 @@ describe("Unit - ai-config command", () => {
105110
const mockFs = createMockFs(JSON.stringify({
106111
servers: { "other-server": thirdPartyServer }
107112
}));
113+
App.container.set(FS_TOKEN, mockFs);
108114

109-
configureMCP(mockFs);
115+
configureMCP();
110116

111117
expect(mockFs.writeFile).toHaveBeenCalled();
112118
const config = writtenConfig(mockFs);
@@ -238,11 +244,6 @@ describe("Unit - ai-config command", () => {
238244
describe("handler", () => {
239245
it("posts analytics and calls configure", async () => {
240246
App.container.set(FS_TOKEN, createMockFs());
241-
const fs = require("fs");
242-
spyOn(fs, "readFileSync").and.throwError(new Error("ENOENT"));
243-
spyOn(fs, "existsSync").and.returnValue(false);
244-
spyOn(fs, "mkdirSync");
245-
spyOn(fs, "writeFileSync");
246247

247248
await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" });
248249

0 commit comments

Comments
 (0)