Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ For VS Code, the `ig ai-config` command handles configuration automatically (see
"mcpServers": {
"igniteui-cli": {
"command": "npx",
"args": ["-y", "igniteui-cli", "mcp"]
"args": ["-y", "@igniteui/mcp-server"]
},
"igniteui-theming": {
"command": "npx",
Expand All @@ -200,6 +200,10 @@ For VS Code, the `ig ai-config` command handles configuration automatically (see
}
```

In a project that lists `igniteui-cli` as a dependency, `ig ai-config` writes `"args": ["-y", "igniteui-cli", "mcp"]` instead, which starts the server bundled with the project's CLI. Use that form there too when configuring manually: with pnpm, `npx -y @igniteui/mcp-server` cannot start inside such a project.

The first start downloads the server into the npx cache, which can take 10 seconds or more, and npx downloads it again after each new release. If your MCP client has a short connection timeout, install the server globally (`npm install -g @igniteui/mcp-server`) and use `"command": "igniteui-mcp"` with no arguments, or pin a version (`"args": ["-y", "@igniteui/mcp-server@<version>"]`). Pinned and global installs don't update on their own, so the bundled documentation stays at that version until you change it.

The MCP server exposes the following tools to AI assistants:

| Tool | Description |
Expand Down
4 changes: 2 additions & 2 deletions packages/core/update/package-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function resolvePackage(packageName: keyof typeof UPGRADEABLE_PACKAGES) {
if (fs.fileExists("./package.json")) {
const fileContent = fs.readFile("./package.json");
const packageJson = JSON.parse(fileContent);
const dependencies = packageJson["dependencies"];
const dependencies = packageJson["dependencies"] ?? {};
const licensed = UPGRADEABLE_PACKAGES[packageName];
if (dependencies[licensed]) {
return licensed;
Expand All @@ -129,7 +129,7 @@ export function getUpgradeablePackages(): PackageDefinition[] {
if (fs.fileExists("./package.json")) {
const fileContent = fs.readFile("./package.json");
const packageJson = JSON.parse(fileContent);
const dependencies = packageJson["dependencies"];
const dependencies = packageJson["dependencies"] ?? {};
for (const packageEntry in UPGRADEABLE_PACKAGES) {
if (dependencies[packageEntry]) {
upgradeable.push({
Expand Down
60 changes: 52 additions & 8 deletions packages/core/util/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,51 @@ export const AI_ASSISTANT_MCP_CONFIGS: Record<AiCodingAssistant, AssistantMcpCon
"junie": { mcpFilePath: ".junie/mcp/mcp.json", rootKey: "mcpServers" },
};

// The key stays "igniteui-cli" so re-running ai-config updates configs written by earlier versions
const IGNITEUI_DOCS_SERVER_KEY = "igniteui-cli";

const IGNITEUI_MCP_SERVERS: Record<string, McpServerEntry> = {
"igniteui-cli": {
[IGNITEUI_DOCS_SERVER_KEY]: {
command: "npx",
args: ["-y", "igniteui-cli", "mcp"]
args: ["-y", "@igniteui/mcp-server"]
},
"igniteui-theming": {
command: "npx",
args: ["-y", "igniteui-theming", "igniteui-theming-mcp"]
}
};

/**
* Docs server entry for projects that list `igniteui-cli`: npx runs the project's own CLI copy, which starts
* its bundled server. Starting `@igniteui/mcp-server` directly fails there under pnpm, because npx finds the
* server as a transitive dependency but pnpm doesn't link its `igniteui-mcp` bin into `node_modules/.bin`.
*/
const IGNITEUI_CLI_DOCS_SERVER: McpServerEntry = {
command: "npx",
args: ["-y", "igniteui-cli", "mcp"]
};

function projectListsIgniteUICli(fileSystem: IFileSystem): boolean {
if (!fileSystem.fileExists("./package.json")) {
return false;
}
try {
const packageJson = JSON.parse(fileSystem.readFile("./package.json"));
return !!(packageJson.dependencies?.["igniteui-cli"] || packageJson.devDependencies?.["igniteui-cli"]);
} catch {
return false;
}
}

function getIgniteUIMcpServers(fileSystem: IFileSystem): Record<string, McpServerEntry> {
if (projectListsIgniteUICli(fileSystem)) {
return { ...IGNITEUI_MCP_SERVERS, [IGNITEUI_DOCS_SERVER_KEY]: IGNITEUI_CLI_DOCS_SERVER };
}
return IGNITEUI_MCP_SERVERS;
}

type Update = [path: jsonc.JSONPath, value: unknown, currentValue: unknown];

/**
* Reads the assistant-specific MCP config file, ensures all IgniteUI MCP servers are present,
* optionally adds additional servers. Creates the file if it doesn't exist.
Expand All @@ -55,7 +89,7 @@ export function addMcpServers(
): boolean {
const { mcpFilePath, rootKey } = AI_ASSISTANT_MCP_CONFIGS[assistant];
const fileSystem = App.container.get<IFileSystem>(FS_TOKEN);
const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS };
const servers = { ...additionalServers, ...getIgniteUIMcpServers(fileSystem) };

let existingContent: string | undefined;
try {
Expand All @@ -80,11 +114,21 @@ export function addMcpServers(
let modified = false;

for (const [key, value] of Object.entries(servers)) {
const edits = jsonc.modify(text, [rootKey, key], value, { formattingOptions });
const newText = jsonc.applyEdits(text, edits);
if (newText !== text) {
text = newText;
modified = true;
const current = existing[key];
// Update only the fields we write, so user-added ones (env, type, ...) survive a re-run
const updates: Update[] = current && typeof current === "object" && !Array.isArray(current)
? Object.entries(value).map(([field, fieldValue]): Update => [[rootKey, key, field], fieldValue, current[field]])
: [[[rootKey, key], value, current]];
for (const [path, update, currentValue] of updates) {
if (JSON.stringify(update) === JSON.stringify(currentValue)) {
continue;
}
const edits = jsonc.modify(text, path, update, { formattingOptions });
const newText = jsonc.applyEdits(text, edits);
if (newText !== text) {
text = newText;
modified = true;
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions packages/igniteui-mcp/Specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Build: Compile the TypeScript source into the `dist/` directory.
"servers": {
"igniteui-cli": {
"command": "npx",
"args": ["-y", "igniteui-cli", "mcp"]
"args": ["-y", "@igniteui/mcp-server"]
}
}
}
Expand All @@ -231,7 +231,7 @@ Build: Compile the TypeScript source into the `dist/` directory.
"mcpServers": {
"igniteui-cli": {
"command": "npx",
"args": ["-y", "igniteui-cli", "mcp"]
"args": ["-y", "@igniteui/mcp-server"]
}
}
}
Expand All @@ -245,7 +245,7 @@ or `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
"mcpServers": {
"igniteui-cli": {
"command": "npx",
"args": ["-y", "igniteui-cli", "mcp"]
"args": ["-y", "@igniteui/mcp-server"]
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { appendFileSync, mkdirSync } from "fs";
import { appendFileSync, mkdirSync, readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
Expand Down Expand Up @@ -64,8 +64,11 @@ const FRAMEWORK_ENUM = z

const DEPRECATION_INSTRUCTIONS = formatDeprecationInstructions();

// Report the published version so clients can tell which release runs (dist/ and src/ both sit one level below package.json)
const { version: SERVER_VERSION } = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));

const server = new McpServer(
{ name: "igniteui-mcp-server", version: "1.0.0" },
{ name: "igniteui-mcp-server", version: SERVER_VERSION },
{
instructions:
"Unified Ignite UI MCP server — documentation, API reference, and CLI scaffolding for 4 frameworks. " +
Expand Down
26 changes: 19 additions & 7 deletions packages/ng-schematics/src/cli-config/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,41 +463,53 @@ export const appConfig: ApplicationConfig = {

expect(tree.exists(mcpFilePath)).toBeTruthy();
const content = JSON.parse(tree.readContent(mcpFilePath));
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] });
});

it("should start the docs server through the project's CLI when package.json lists igniteui-cli", async () => {
const pkgJson = JSON.parse(tree.readContent("/package.json"));
pkgJson.devDependencies["igniteui-cli"] = "~15.8.2";
tree.overwrite("/package.json", JSON.stringify(pkgJson));

await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree);

const content = JSON.parse(tree.readContent(mcpFilePath));
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
});

it("should add all three servers to existing .vscode/mcp.json that has no servers", async () => {
tree.create(mcpFilePath, JSON.stringify({ servers: {} }));

await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree);

const content = JSON.parse(tree.readContent(mcpFilePath));
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] });
});

it("should add missing servers when only some are already present", async () => {
tree.create(mcpFilePath, JSON.stringify({
servers: {
"igniteui-cli": { command: "npx", args: ["-y", "igniteui-cli", "mcp"] }
"igniteui-cli": { command: "npx", args: ["-y", "@igniteui/mcp-server"] }
}
}));

await runner.runSchematic("ai-config", { assistants: ["vscode"] }, tree);

const content = JSON.parse(tree.readContent(mcpFilePath));
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
expect(content.servers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
expect(content.servers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] });
});

it("should not modify .vscode/mcp.json if all servers are already present", async () => {
const existing = {
servers: {
"igniteui-cli": { command: "npx", args: ["-y", "igniteui-cli", "mcp"] },
"igniteui-cli": { command: "npx", args: ["-y", "@igniteui/mcp-server"] },
"igniteui-theming": { command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] },
"angular-cli": { command: "npx", args: ["-y", "@angular/cli", "mcp"] }
}
Expand Down Expand Up @@ -562,7 +574,7 @@ export const appConfig: ApplicationConfig = {
expect(tree.exists(filePath)).toBeTruthy();
const content = JSON.parse(tree.readContent(filePath));
expect(content.servers).toBeDefined();
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.servers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
});

it("should write to .cursor/mcp.json with mcpServers key when assistant is cursor", async () => {
Expand All @@ -572,7 +584,7 @@ export const appConfig: ApplicationConfig = {
expect(tree.exists(filePath)).toBeTruthy();
const content = JSON.parse(tree.readContent(filePath));
expect(content.mcpServers).toBeDefined();
expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
expect(content.mcpServers["angular-cli"]).toEqual({ command: "npx", args: ["-y", "@angular/cli", "mcp"] });
expect(content.servers).toBeUndefined();
});
Expand Down
3 changes: 2 additions & 1 deletion packages/ng-schematics/src/ng-new/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ describe("Schematics ng-new", () => {

expect(e.exists(mcpFilePath)).toBeTruthy();
const content = JSON.parse(e.readContent(mcpFilePath));
expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "igniteui-cli", "mcp"] });
// ng-new projects list @igniteui/angular-schematics instead of igniteui-cli, so the server package is started directly
expect(content.mcpServers["igniteui-cli"]).toEqual({ command: "npx", args: ["-y", "@igniteui/mcp-server"] });
expect(content.mcpServers["igniteui-theming"]).toEqual({ command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] });
});
});
Expand Down
114 changes: 113 additions & 1 deletion spec/unit/ai-config-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { PassThrough, Writable } from "stream";

const IGNITEUI_SERVER_KEY = "igniteui-cli";
const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming";
const igniteuiServer = { command: "npx", args: ["-y", "igniteui-cli", "mcp"] };
const igniteuiServer = { command: "npx", args: ["-y", "@igniteui/mcp-server"] };
const igniteuiCliServer = { command: "npx", args: ["-y", "igniteui-cli", "mcp"] };
const igniteuiThemingServer = { command: "npx", args: ["-y", "igniteui-theming", "igniteui-theming-mcp"] };

function createMockFs(existingContent?: string): IFileSystem {
Expand All @@ -26,6 +27,21 @@ function createMockFs(existingContent?: string): IFileSystem {
};
}

function createProjectMockFs(packageJson: object, existingContent?: string): IFileSystem {
const mockFs = createMockFs(existingContent);
(mockFs.fileExists as jasmine.Spy).and.callFake((p: string) => p === "./package.json");
(mockFs.readFile as jasmine.Spy).and.callFake((p: string) => {
if (p === "./package.json") {
return JSON.stringify(packageJson);
}
if (existingContent) {
return existingContent;
}
throw new Error("ENOENT");
});
return mockFs;
}

describe("Unit - ai-config command", () => {
const configPath = path.join(process.cwd(), ".vscode", "mcp.json");

Expand Down Expand Up @@ -157,6 +173,102 @@ 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("starts the server through the project's CLI when package.json lists igniteui-cli in devDependencies", () => {
const mockFs = createProjectMockFs({ devDependencies: { "igniteui-cli": "~15.8.2" } });
App.container.set(FS_TOKEN, mockFs);

configureMCP(["vscode"]);

const config = writtenConfig(mockFs);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiCliServer);
expect((config.servers as any)[IGNITEUI_THEMING_SERVER_KEY]).toEqual(igniteuiThemingServer);
});

it("starts the server through the project's CLI when package.json lists igniteui-cli in dependencies", () => {
const mockFs = createProjectMockFs({ dependencies: { "igniteui-cli": "~15.8.2" } });
App.container.set(FS_TOKEN, mockFs);

configureMCP(["vscode"]);

const config = writtenConfig(mockFs);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiCliServer);
});

it("starts the server package when package.json does not list igniteui-cli", () => {
const mockFs = createProjectMockFs({ devDependencies: { "typescript": "~5.9.0" } });
App.container.set(FS_TOKEN, mockFs);

configureMCP(["vscode"]);

const config = writtenConfig(mockFs);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer);
});

it("starts the server package when package.json cannot be parsed", () => {
const mockFs = createMockFs();
(mockFs.fileExists as jasmine.Spy).and.callFake((p: string) => p === "./package.json");
(mockFs.readFile as jasmine.Spy).and.callFake((p: string) => {
if (p === "./package.json") {
return "{ not json";
}
throw new Error("ENOENT");
});
App.container.set(FS_TOKEN, mockFs);

configureMCP(["vscode"]);

const config = writtenConfig(mockFs);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer);
});

it("replaces the igniteui-cli mcp entry of earlier versions when the project does not list igniteui-cli", () => {
const mockFs = createMockFs(JSON.stringify({
servers: {
[IGNITEUI_SERVER_KEY]: igniteuiCliServer,
[IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer
}
}, null, 2));
App.container.set(FS_TOKEN, mockFs);

const result = addMcpServers("vscode");

expect(result).toBe(true);
const config = writtenConfig(mockFs);
expect(Object.keys(config.servers as object)).toEqual([IGNITEUI_SERVER_KEY, IGNITEUI_THEMING_SERVER_KEY]);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer);
});

it("keeps user-added fields of an existing entry when updating it", () => {
const env = { IGNITEUI_MCP_DOCS_BACKEND_URL: "https://example.com" };
const mockFs = createMockFs(JSON.stringify({
servers: {
[IGNITEUI_SERVER_KEY]: { type: "stdio", ...igniteuiCliServer, env },
[IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer
}
}, null, 2));
App.container.set(FS_TOKEN, mockFs);

addMcpServers("vscode");

const config = writtenConfig(mockFs);
expect((config.servers as any)[IGNITEUI_SERVER_KEY]).toEqual({ type: "stdio", ...igniteuiServer, env });
});

it("does not rewrite a configured file whose formatting differs from the CLI's", () => {
const mockFs = createMockFs(JSON.stringify({
servers: {
[IGNITEUI_SERVER_KEY]: igniteuiServer,
[IGNITEUI_THEMING_SERVER_KEY]: igniteuiThemingServer
}
}));
App.container.set(FS_TOKEN, mockFs);

const result = addMcpServers("vscode");

expect(result).toBe(false);
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
});

describe("exclusive checkbox behavior", () => {
Expand Down
Loading
Loading