From ff89c0c6826734a52467284ffbb209ff878e28f6 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Mon, 2 Nov 2026 10:18:46 +0200 Subject: [PATCH] fix(mcp): start the docs server from @igniteui/mcp-server in ai-config ai-config now writes `npx -y @igniteui/mcp-server` instead of `npx -y igniteui-cli mcp`, except in projects that list igniteui-cli, where pnpm can't start the server package directly. Re-running ai-config updates only command and args, keeping user-added fields. The server reports its real version in serverInfo, and resolvePackage and getUpgradeablePackages no longer crash on a package.json without a dependencies field. --- README.md | 6 +- packages/core/update/package-resolve.ts | 4 +- packages/core/util/mcp-config.ts | 60 +++++++-- packages/igniteui-mcp/Specification.md | 6 +- .../igniteui-doc-mcp/src/index.ts | 7 +- .../src/cli-config/index_spec.ts | 26 ++-- .../ng-schematics/src/ng-new/index_spec.ts | 3 +- spec/unit/ai-config-spec.ts | 114 +++++++++++++++++- spec/unit/packageResolve-spec.ts | 18 +++ 9 files changed, 219 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7048a85dc3..0aca5938e0 100644 --- a/README.md +++ b/README.md @@ -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", @@ -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@"]`). 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 | diff --git a/packages/core/update/package-resolve.ts b/packages/core/update/package-resolve.ts index 3149106b84..91d17fe77b 100644 --- a/packages/core/update/package-resolve.ts +++ b/packages/core/update/package-resolve.ts @@ -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; @@ -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({ diff --git a/packages/core/util/mcp-config.ts b/packages/core/util/mcp-config.ts index b860102ad6..d7f5b9516d 100644 --- a/packages/core/util/mcp-config.ts +++ b/packages/core/util/mcp-config.ts @@ -31,10 +31,13 @@ export const AI_ASSISTANT_MCP_CONFIGS: Record = { - "igniteui-cli": { + [IGNITEUI_DOCS_SERVER_KEY]: { command: "npx", - args: ["-y", "igniteui-cli", "mcp"] + args: ["-y", "@igniteui/mcp-server"] }, "igniteui-theming": { command: "npx", @@ -42,6 +45,37 @@ const IGNITEUI_MCP_SERVERS: Record = { } }; +/** + * 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 { + 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. @@ -55,7 +89,7 @@ export function addMcpServers( ): boolean { const { mcpFilePath, rootKey } = AI_ASSISTANT_MCP_CONFIGS[assistant]; const fileSystem = App.container.get(FS_TOKEN); - const servers = { ...additionalServers, ...IGNITEUI_MCP_SERVERS }; + const servers = { ...additionalServers, ...getIgniteUIMcpServers(fileSystem) }; let existingContent: string | undefined; try { @@ -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; + } } } diff --git a/packages/igniteui-mcp/Specification.md b/packages/igniteui-mcp/Specification.md index a050408961..5fb6944d2f 100644 --- a/packages/igniteui-mcp/Specification.md +++ b/packages/igniteui-mcp/Specification.md @@ -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"] } } } @@ -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"] } } } @@ -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"] } } } diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts index 3ff01e44a3..16cb8eae49 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts @@ -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"; @@ -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. " + diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index f30b34358e..0ee963926c 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -463,18 +463,30 @@ 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"] }); }); @@ -482,14 +494,14 @@ export const appConfig: ApplicationConfig = { 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"] }); }); @@ -497,7 +509,7 @@ export const appConfig: ApplicationConfig = { 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"] } } @@ -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 () => { @@ -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(); }); diff --git a/packages/ng-schematics/src/ng-new/index_spec.ts b/packages/ng-schematics/src/ng-new/index_spec.ts index b288b24b78..6022ca3ce8 100644 --- a/packages/ng-schematics/src/ng-new/index_spec.ts +++ b/packages/ng-schematics/src/ng-new/index_spec.ts @@ -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"] }); }); }); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 050aea2097..eb8f515c06 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -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 { @@ -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"); @@ -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", () => { diff --git a/spec/unit/packageResolve-spec.ts b/spec/unit/packageResolve-spec.ts index e7a73c9552..eea6f7b6fa 100644 --- a/spec/unit/packageResolve-spec.ts +++ b/spec/unit/packageResolve-spec.ts @@ -67,6 +67,15 @@ describe("Igx templates - package resolve", () => { result = resolvePackage(NPM_DOCK_MANAGER); expect(result).toEqual(FEED_DOCK_MANAGER); }); + + it("should return npm package if package.json has no dependencies field", () => { + const mockFs = new MockFileSystem(); + spyOn(mockFs, "fileExists").and.returnValue(true); + spyOn(mockFs, "readFile").and.returnValue(`{ "devDependencies": { "typescript": "*" } }`); + spyOn(App.container, "get").and.returnValue(mockFs); + + expect(resolvePackage(NPM_DOCK_MANAGER)).toEqual(NPM_DOCK_MANAGER); + }); }); describe("getUpgradeablePackages", () => { @@ -88,6 +97,15 @@ describe("Igx templates - package resolve", () => { expect(getUpgradeablePackages()).toEqual([]); }); + it("should return an empty array if package.json has no dependencies field", () => { + const mockFs = new MockFileSystem(); + spyOn(mockFs, "fileExists").and.returnValue(true); + spyOn(mockFs, "readFile").and.returnValue(`{ "devDependencies": { "igniteui-angular": "*" } }`); + spyOn(App.container, "get").and.returnValue(mockFs); + + expect(getUpgradeablePackages()).toEqual([]); + }); + it("should return an array if containing ONLY upgradeable packages", () => { const mockFs = new MockFileSystem(); const existsSpy = spyOn(mockFs, "fileExists").and.returnValue(false);