diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index ba2bf6025..b376b7e00 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -1,11 +1,11 @@ import { AI_AGENT_LABELS, AI_AGENT_SKILLS_DIRS, AIAgentTarget, - BasePromptSession, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, + BasePromptSession, configureMcpForAgents, GoogleAnalytics, InquirerWrapper, PackageManager, ProjectConfig, ProjectLibrary, PromptTaskContext, Task, Util } from "@igniteui/cli-core"; import * as path from "path"; import { default as add } from "./commands/add"; -import { configure, configureMCP } from "./commands/ai-config"; +import { configure } from "./commands/ai-config"; import { default as start } from "./commands/start"; import { default as upgrade } from "./commands/upgrade"; import { TemplateManager } from "./TemplateManager"; @@ -125,7 +125,7 @@ export class PromptSession extends BasePromptSession { protected async configureAI(): Promise { // skip adding skills since those are baked into the project template atm: - configureMCP(); + configureMcpForAgents([]); } /** diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 8396e085b..4ab9bbf78 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,16 +1,6 @@ -import { addMcpServers, AI_AGENT_LABELS, AI_AGENT_SKILLS_DIRS, AIAgentTarget, copyAgentInstructionFiles, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, InquirerWrapper, Util, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { AI_AGENT_LABELS, AI_AGENT_SKILLS_DIRS, AIAgentTarget, configureMcpForAgents, copyAgentInstructionFiles, copyAISkillsToProject, getSkillsDir, GoogleAnalytics, InquirerWrapper, Util } from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; -export function configureMCP(): void { - const modified = addMcpServers(VS_CODE_MCP_PATH); - - if (!modified) { - Util.log(` Ignite UI MCP servers already configured in ${VS_CODE_MCP_PATH}`); - return; - } - Util.log(Util.greenCheck() + ` MCP servers configured in ${VS_CODE_MCP_PATH}`); -} - export function configureSkills(skillsDir: string): void { const result = copyAISkillsToProject(skillsDir); if (result.found === 0) { @@ -27,7 +17,7 @@ export function configureSkills(skillsDir: string): void { } export function configure(agents: AIAgentTarget[]): void { - configureMCP(); + configureMcpForAgents(agents); for (const agent of agents) { configureSkills(getSkillsDir(agent)); } diff --git a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__vscode/mcp.json b/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__vscode/mcp.json deleted file mode 100644 index f1fe46dd4..000000000 --- a/packages/cli/templates/react/igr-ts/projects/_base/files/__dot__vscode/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "servers": { - "igniteui-cli": { - "command": "npx", - "args": ["-y", "igniteui-cli", "mcp"] - }, - "igniteui-theming": { - "command": "npx", - "args": ["-y", "igniteui-theming", "igniteui-theming-mcp"] - } - } -} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__vscode/mcp.json b/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__vscode/mcp.json deleted file mode 100644 index f1fe46dd4..000000000 --- a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/__dot__vscode/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "servers": { - "igniteui-cli": { - "command": "npx", - "args": ["-y", "igniteui-cli", "mcp"] - }, - "igniteui-theming": { - "command": "npx", - "args": ["-y", "igniteui-theming", "igniteui-theming-mcp"] - } - } -} diff --git a/packages/core/util/mcp-config.ts b/packages/core/util/mcp-config.ts index bf41928df..adafbd9f8 100644 --- a/packages/core/util/mcp-config.ts +++ b/packages/core/util/mcp-config.ts @@ -1,6 +1,7 @@ import { FS_TOKEN, IFileSystem } from "../types/FileSystem"; import * as jsonc from "jsonc-parser"; import { App } from "./App"; +import { AIAgentTarget } from "./ai-skills"; export interface McpServerEntry { command: string; @@ -19,16 +20,28 @@ const IGNITEUI_MCP_SERVERS: Record = { }; export const VS_CODE_MCP_PATH = ".vscode/mcp.json"; +export const CURSOR_MCP_PATH = ".cursor/mcp.json"; + +/** Project-level MCP config file paths and their servers root key, keyed by AI agent target. + * Agents not listed here do not have a project-level MCP config and will only receive the + * baseline VS Code configuration when `configureMcpForAgents` is called. + */ +export const AI_AGENT_MCP_CONFIGS: Partial> = { + cursor: { path: CURSOR_MCP_PATH, serversKey: "mcpServers" } +}; /** - * Reads .vscode/mcp.json, ensures all IgniteUI MCP servers are present, + * Reads a MCP config JSON file, ensures all IgniteUI MCP servers are present, * optionally adds additional servers. Creates the file if it doesn't exist. + * @param mcpFilePath path to the MCP config file * @param additionalServers optional extra servers to include alongside the built-in ones + * @param serversKey root key used for the servers object in the JSON (default: "servers") * @returns whether the file was modified */ export function addMcpServers( mcpFilePath: string, - additionalServers?: Record + additionalServers?: Record, + serversKey = "servers" ): boolean { const fileSystem = App.container.get(FS_TOKEN); const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS }; @@ -44,12 +57,12 @@ export function addMcpServers( if (Object.keys(servers).length === 0) { return false; } - fileSystem.writeFile(mcpFilePath, JSON.stringify({ servers }, null, 2) + "\n"); + fileSystem.writeFile(mcpFilePath, JSON.stringify({ [serversKey]: servers }, null, 2) + "\n"); return true; } const parsed = jsonc.parse(existingContent); - const existing = parsed.servers ?? {}; + const existing = parsed[serversKey] ?? {}; const formattingOptions: jsonc.FormattingOptions = { tabSize: 2, insertSpaces: true }; let text = existingContent; @@ -57,7 +70,7 @@ export function addMcpServers( for (const [key, value] of Object.entries(servers)) { if (!existing[key]) { - const edits = jsonc.modify(text, ["servers", key], value, { formattingOptions }); + const edits = jsonc.modify(text, [serversKey, key], value, { formattingOptions }); text = jsonc.applyEdits(text, edits); modified = true; } @@ -69,3 +82,27 @@ export function addMcpServers( return modified; } + +/** + * Writes MCP server configuration for the selected AI agents. + * Always writes `.vscode/mcp.json` (VS Code baseline) — even when `agents` is empty. + * Also writes `.cursor/mcp.json` (with `mcpServers` key) when `cursor` is in the agents list. + * Agents without an entry in `AI_AGENT_MCP_CONFIGS` only receive the VS Code baseline file. + * @param agents list of AI agent targets (pass an empty array to configure VS Code only) + * @param additionalServers optional extra servers to include alongside the built-in ones + */ +export function configureMcpForAgents( + agents: AIAgentTarget[], + additionalServers?: Record +): void { + // VS Code is always written as the baseline + addMcpServers(VS_CODE_MCP_PATH, additionalServers, "servers"); + + // Write agent-specific config files for agents that have a project-level MCP config + for (const agent of agents) { + const agentConfig = AI_AGENT_MCP_CONFIGS[agent]; + if (agentConfig) { + addMcpServers(agentConfig.path, additionalServers, agentConfig.serversKey); + } + } +} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/mcp.json b/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/mcp.json deleted file mode 100644 index 2a2df3497..000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "servers": { - "angular-cli": { - "command": "npx", - "args": ["-y", "@angular/cli", "mcp"] - }, - "igniteui-cli": { - "command": "npx", - "args": ["-y", "igniteui-cli", "mcp"] - }, - "igniteui-theming": { - "command": "npx", - "args": ["-y", "igniteui-theming", "igniteui-theming-mcp"] - } - } -} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index bb76300e2..057184456 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -1,7 +1,7 @@ import * as ts from "typescript"; import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; -import { addClassToBody, addMcpServers, AIAgentTarget, App, copyAgentInstructionFiles, copyAISkillsToProject, FormatSettings, getSkillsDir, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils, VS_CODE_MCP_PATH } from "@igniteui/cli-core"; +import { addClassToBody, AIAgentTarget, App, configureMcpForAgents, copyAgentInstructionFiles, copyAISkillsToProject, FormatSettings, getSkillsDir, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; @@ -143,7 +143,7 @@ function aiConfig({ init, agents }: { init: boolean; agents: AIAgentTarget[] }): } }; - addMcpServers(VS_CODE_MCP_PATH, angularCliServer); + configureMcpForAgents(agents, angularCliServer); }; } diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index 6f6be65ee..fdad8a7ad 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -435,6 +435,16 @@ export const appConfig: ApplicationConfig = { expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"]); }); + it("should create .cursor/mcp.json with mcpServers key when cursor agent is selected", async () => { + await runner.runSchematic("ai-config", { agent: ["cursor"] }, tree); + + expect(tree.exists("/.cursor/mcp.json")).toBeTruthy(); + const content = JSON.parse(tree.readContent("/.cursor/mcp.json")); + expect(content.mcpServers).toBeDefined(); + expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] }); + expect(content.mcpServers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] }); + }); + it("should configure multiple agents", async () => { await runner.runSchematic("ai-config", { agent: ["claude", "cursor"] }, tree); diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index 23b4f98ea..862c64411 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -9,6 +9,9 @@ import { PromptSession } from "../../packages/cli/lib/PromptSession"; import { TemplateManager } from "../../packages/cli/lib/TemplateManager"; import { Separator } from "@inquirer/prompts"; +// Require the leaf module directly so spyOn works (same pattern as index_spec.ts) +const mcpConfigModule = require("@igniteui/cli-core/util/mcp-config"); + function createMockBaseTemplate(): BaseTemplate { return { id: "mock-template-id", @@ -102,7 +105,7 @@ describe("Unit - PromptSession", () => { }); beforeEach(() => { - spyOn(aiConfig, "configureMCP"); + spyOn(mcpConfigModule, "configureMcpForAgents"); }); // TODO: most of the tests use same setup - move the setup to beforeAll call @@ -504,7 +507,7 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(mcpConfigModule.configureMcpForAgents).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); expect(InquirerWrapper.input).toHaveBeenCalledWith({ @@ -583,7 +586,7 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(mcpConfigModule.configureMcpForAgents).toHaveBeenCalledTimes(1); expect(Util.getAvailableName).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); @@ -709,7 +712,7 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledTimes(3); expect(PackageManager.flushQueue).toHaveBeenCalledWith(true); expect(start.start).toHaveBeenCalledTimes(1); - expect(aiConfig.configureMCP).toHaveBeenCalledTimes(1); + expect(mcpConfigModule.configureMcpForAgents).toHaveBeenCalledTimes(1); expect(add.addTemplate).toHaveBeenCalledTimes(1); expect(InquirerWrapper.checkbox).toHaveBeenCalledWith({ diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 249905842..35b53f7ab 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -1,6 +1,5 @@ -import * as path from "path"; -import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; -import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config"; +import { App, Config, configureMcpForAgents, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { configureSkills } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; const IGNITEUI_SERVER_KEY = "igniteui-cli"; @@ -21,8 +20,6 @@ function createMockFs(existingContent?: string): IFileSystem { } describe("Unit - ai-config command", () => { - const configPath = path.join(process.cwd(), ".vscode", "mcp.json"); - beforeAll(() => { spyOn(GoogleAnalytics, "post"); }); @@ -37,12 +34,12 @@ describe("Unit - ai-config command", () => { return JSON.parse(content); } - describe("configureMCP", () => { + describe("configureMcpForAgents", () => { it("creates .vscode/mcp.json with both servers when file does not exist", () => { const mockFs = createMockFs(); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -54,7 +51,7 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(JSON.stringify({ servers: {} })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -68,7 +65,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -82,7 +79,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -90,7 +87,7 @@ describe("Unit - ai-config command", () => { expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); }); - it("is a no-op and logs when both servers are already configured", () => { + it("is a no-op for .vscode/mcp.json when both servers are already configured", () => { const mockFs = createMockFs(JSON.stringify({ servers: { [IGNITEUI_SERVER_KEY]: igniteuiServer, @@ -99,10 +96,9 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).not.toHaveBeenCalled(); - expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already configured")); }); it("preserves existing third-party servers when adding igniteui servers", () => { @@ -112,7 +108,7 @@ describe("Unit - ai-config command", () => { })); App.container.set(FS_TOKEN, mockFs); - configureMCP(); + configureMcpForAgents([]); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -120,6 +116,45 @@ describe("Unit - ai-config command", () => { expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); }); + + it("also creates .cursor/mcp.json with mcpServers key when cursor is in agents", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + configureMcpForAgents(["cursor"]); + + // Two writes: .vscode/mcp.json and .cursor/mcp.json + const calls = (mockFs.writeFile as jasmine.Spy).calls.all(); + expect(calls.length).toBe(2); + + const vscodePath = calls[0].args[0] as string; + const cursorPath = calls[1].args[0] as string; + expect(vscodePath).toContain(".vscode/mcp.json"); + expect(cursorPath).toContain(".cursor/mcp.json"); + + const vscodeConfig = JSON.parse(calls[0].args[1]); + const cursorConfig = JSON.parse(calls[1].args[1]); + + // VS Code uses "servers" key + expect(vscodeConfig.servers).toBeDefined(); + expect(vscodeConfig.servers[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + + // Cursor uses "mcpServers" key + expect(cursorConfig.mcpServers).toBeDefined(); + expect(cursorConfig.mcpServers[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); + expect(cursorConfig.mcpServers[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer); + }); + + it("does NOT create .cursor/mcp.json when cursor is not in agents", () => { + const mockFs = createMockFs(); + App.container.set(FS_TOKEN, mockFs); + + configureMcpForAgents(["claude"]); + + const calls = (mockFs.writeFile as jasmine.Spy).calls.all(); + const paths = calls.map((c: jasmine.CallInfo) => c.args[0] as string); + expect(paths.some((p: string) => p.includes(".cursor"))).toBeFalse(); + }); }); describe("configureSkills", () => {