From c60871ffe930cf8c071b832e5882b59a59338b61 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Mon, 2 Nov 2026 10:58:32 +0200 Subject: [PATCH 1/3] perf(cli): start the MCP server in-process before the CLI loads `ig mcp` loaded the whole CLI (yargs, every template) and then spawned the server as a second Node process. bin/execute.js now finds the server installed with the selected CLI copy and imports it directly, which halves the warm start (about 1.1 s to 0.45 s) and runs one process per session. Help, version, other options and a missing or unbuilt server still go through the CLI. The server lookup follows the CLI copy's real path, so it works in pnpm projects. The global/local version notice now goes to stderr, as stdout carries the MCP protocol. --- packages/cli/bin/execute.js | 41 +++++++++++++++----- packages/cli/lib/commands/mcp.ts | 37 ++++++++++++++++++ spec/acceptance/help-spec.ts | 18 +++++++++ spec/acceptance/mcp-spec.ts | 64 ++++++++++++++++++++++++++++++++ spec/unit/mcp-cli-spec.ts | 46 ++++++++++++++++++++++- 5 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 spec/acceptance/mcp-spec.ts diff --git a/packages/cli/bin/execute.js b/packages/cli/bin/execute.js index 2333695dae..758d8904b2 100755 --- a/packages/cli/bin/execute.js +++ b/packages/cli/bin/execute.js @@ -2,29 +2,52 @@ const fs = require("fs"); const resolve = require("resolve"); const path = require("path"); +const { pathToFileURL } = require("url"); const { hideBin } = require("yargs/helpers"); const args = hideBin(process.argv); //execute -g igniteui-cli in case there is no project installed, else resolve to the local one resolve("igniteui-cli", { basedir: process.cwd() }, function (err, res) { - let cli; + let cliPath = "../lib/cli"; if (res && res.indexOf(process.cwd()) > -1) { const localVersion = require(path.join(process.cwd(), "node_modules/igniteui-cli/package.json"))["version"]; const globalVersion = require(path.join(__dirname, "../package.json"))["version"]; if (globalVersion !== localVersion) { - console.log("Different igniteui-cli global and local version"); - } - if (args[0] === "ai-config") { - cli = require("../lib/cli"); - } else { - cli = require(res); + // stderr, because stdout carries the protocol messages when `ig mcp` runs the MCP server + console.error("Different igniteui-cli global and local version"); } - } else { - cli = require("../lib/cli"); + if (args[0] !== "ai-config") { + cliPath = res; + } + } + if (args[0] === "mcp" && startMcpServer(path.dirname(require.resolve(cliPath)))) { + return; } + const cli = require(cliPath); cli.run(args).catch(function (err) { console.error("Error: " + (err.message || err)); process.exit(1); }); }); + +/** + * Starts the MCP server bundled with the CLI copy in `cliDir` in this process, before the CLI loads, so a session + * runs one Node process and skips loading the CLI and its templates. + * @returns false when `ig mcp` should run through the CLI instead (help, other options, server missing or not built) + */ +function startMcpServer(cliDir) { + const { getMcpServerArgs, resolveMcpServerEntry } = require("../lib/commands/mcp"); + const serverArgs = getMcpServerArgs(args.slice(1)); + const mcpEntry = serverArgs && resolveMcpServerEntry(cliDir); + if (!mcpEntry) { + return false; + } + // the server reads its options from process.argv when it loads + process.argv = [process.argv[0], mcpEntry, ...serverArgs]; + import(pathToFileURL(mcpEntry).href).catch(function (err) { + console.error("Failed to start MCP server: " + (err.message || err)); + process.exit(1); + }); + return true; +} diff --git a/packages/cli/lib/commands/mcp.ts b/packages/cli/lib/commands/mcp.ts index a26ddf5c2e..557e9b9437 100644 --- a/packages/cli/lib/commands/mcp.ts +++ b/packages/cli/lib/commands/mcp.ts @@ -4,6 +4,43 @@ import { spawn } from "child_process"; import { CommandType, PositionalArgs } from "./types"; import { ArgumentsCamelCase } from "yargs"; +/** + * Resolves the built MCP server entry installed with the CLI copy in `cliDir`. + * @returns the entry path, or `null` when the server package is missing or not built + */ +export function resolveMcpServerEntry(cliDir: string): string | null { + try { + // resolve from the real folder: pnpm links packages and keeps their dependencies next to the link target + const mcpPkgJson = require.resolve("@igniteui/mcp-server/package.json", { paths: [fs.realpathSync(cliDir)] }); + const mcpEntry = path.resolve(path.dirname(mcpPkgJson), "dist", "index.js"); + return fs.existsSync(mcpEntry) ? mcpEntry : null; + } catch { + return null; + } +} + +/** + * Maps the options of `ig mcp` to the server's own command line, which reads only `--remote ` and `--debug`. + * @param args the arguments after `mcp` + * @returns the server arguments, or `null` when the CLI must parse them (help, version, other or incomplete options) + */ +export function getMcpServerArgs(args: string[]): string[] | null { + const serverArgs: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--debug") { + serverArgs.push(arg); + } else if (arg === "--remote" && args[i + 1] && !args[i + 1].startsWith("-")) { + serverArgs.push(arg, args[++i]); + } else if (arg.startsWith("--remote=") && arg.length > "--remote=".length) { + serverArgs.push("--remote", arg.slice("--remote=".length)); + } else { + return null; + } + } + return serverArgs; +} + const command: CommandType = { command: "mcp", describe: "Starts the Ignite UI MCP server for AI assistant integration", diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 20dbaafc52..b6061e6c38 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -254,6 +254,24 @@ describe("Help command", () => { expect(actualNewText).toContain(replacedNewHelpText); }); + it("should show help for the mcp command", async () => { + const child = Util.spawnSync("node", [execLocation, "mcp", "-h"], { + encoding: "utf-8" + }); + + const originalHelpText: string = ` + Options: + -v, --version Show current Ignite UI CLI version [boolean] + -h, --help Show help [boolean] + --remote Backend URL for remote mode (uses HTTP instead of local SQLite) [string] + --debug Enable debug logging to mcp-server.log [boolean] [default: false]`; + + const replacedHelpText: string = originalHelpText.replace(/\s/g, ""); + const actualNewText: string = (child.stdout.toString()).replace(/\s/g, ""); + + expect(actualNewText).toContain(replacedHelpText); + }); + it("should show help for the ai-config command", async () => { const child = Util.spawnSync("node", [execLocation, "ai-config", "-h"], { encoding: "utf-8" diff --git a/spec/acceptance/mcp-spec.ts b/spec/acceptance/mcp-spec.ts new file mode 100644 index 0000000000..22967481d4 --- /dev/null +++ b/spec/acceptance/mcp-spec.ts @@ -0,0 +1,64 @@ +import { spawn } from "child_process"; + +const execLocation = "packages/cli/bin/execute.js"; + +interface McpRun { + response: any; + nonProtocolLines: string[]; + exitCode: number | null; +} + +/** Starts `ig mcp`, sends `initialize`, and closes stdin once the server answers. */ +function initializeMcpServer(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [execLocation, "mcp", ...args], { stdio: ["pipe", "pipe", "pipe"] }); + const nonProtocolLines: string[] = []; + let response: any; + let buffer = ""; + + child.stdout.on("data", (data: Buffer) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines.filter(l => l.trim())) { + let message: any; + try { + message = JSON.parse(line); + } catch { + nonProtocolLines.push(line); + continue; + } + if (message.id === 1) { + response = message; + child.stdin.end(); + } + } + }); + child.on("error", reject); + child.on("exit", exitCode => resolve({ response, nonProtocolLines, exitCode })); + + child.stdin.write(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcp-spec", version: "0" } } + }) + "\n"); + }); +} + +describe("MCP command", () => { + it("starts the MCP server, which answers initialize over stdio and exits when stdin closes", async () => { + const run = await initializeMcpServer([]); + + expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); + expect(run.nonProtocolLines).toEqual([]); + expect(run.exitCode).toBe(0); + }, 30000); + + it("passes --remote= to the server", async () => { + const run = await initializeMcpServer(["--remote=http://127.0.0.1:9"]); + + expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); + expect(run.nonProtocolLines).toEqual([]); + }, 30000); +}); diff --git a/spec/unit/mcp-cli-spec.ts b/spec/unit/mcp-cli-spec.ts index bd988b396c..efca1b507f 100644 --- a/spec/unit/mcp-cli-spec.ts +++ b/spec/unit/mcp-cli-spec.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "events"; import fs = require("fs"); import * as path from "path"; import yargs from "yargs"; -import mcp from "../../packages/cli/lib/commands/mcp"; +import mcp, { getMcpServerArgs, resolveMcpServerEntry } from "../../packages/cli/lib/commands/mcp"; function createFakeChildProcess(): EventEmitter { return new EventEmitter(); @@ -190,4 +190,48 @@ describe("Unit - MCP CLI command", () => { expect(message).toContain("boom"); }); }); + + describe("getMcpServerArgs", () => { + it("returns no arguments when none are given", () => { + expect(getMcpServerArgs([])).toEqual([]); + }); + + it("passes --remote and --debug through", () => { + expect(getMcpServerArgs(["--remote", "https://docs.example.test", "--debug"])) + .toEqual(["--remote", "https://docs.example.test", "--debug"]); + }); + + it("splits --remote=, which the server does not parse", () => { + expect(getMcpServerArgs(["--debug", "--remote=https://docs.example.test"])) + .toEqual(["--debug", "--remote", "https://docs.example.test"]); + }); + + it("leaves help, version, incomplete and other options to the CLI", () => { + for (const args of [["--help"], ["-h"], ["--version"], ["-v"], ["--remote"], ["--remote", "--debug"], + ["--remote="], ["--debug=false"], ["--no-debug"], ["extra"]]) { + expect(getMcpServerArgs(args)).withContext(args.join(" ")).toBeNull(); + } + }); + }); + + describe("resolveMcpServerEntry", () => { + const cliDir = path.join(process.cwd(), "packages", "cli", "lib"); + + it("returns the built server entry installed with the CLI", () => { + expect(resolveMcpServerEntry(cliDir)).toBe(fs.realpathSync(mcpEntry)); + }); + + it("returns null when the server package cannot be resolved", () => { + mockMcpPackageResolution(undefined, true); + + expect(resolveMcpServerEntry(cliDir)).toBeNull(); + }); + + it("returns null when the server is not built", () => { + mockMcpPackageResolution(mcpPackageJson); + spyOn(fs, "existsSync").and.returnValue(false); + + expect(resolveMcpServerEntry(cliDir)).toBeNull(); + }); + }); }); From b777318086c86e991465d62fc6e760c1232d23bb Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 3 Nov 2026 11:17:11 +0200 Subject: [PATCH 2/3] test(cli): kill a hung MCP server in the acceptance spec The spec now has its own deadline, shorter than the Jasmine timeout, that kills the child and rejects, so a server that never answers initialize or never exits doesn't outlive the spec. --- spec/acceptance/mcp-spec.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/spec/acceptance/mcp-spec.ts b/spec/acceptance/mcp-spec.ts index 22967481d4..ebb1c5de27 100644 --- a/spec/acceptance/mcp-spec.ts +++ b/spec/acceptance/mcp-spec.ts @@ -1,6 +1,9 @@ import { spawn } from "child_process"; const execLocation = "packages/cli/bin/execute.js"; +// shorter than the specs' Jasmine timeout, so a hung server is killed instead of outliving the spec +const MCP_RUN_DEADLINE_MS = 20000; +const SPEC_TIMEOUT_MS = 30000; interface McpRun { response: any; @@ -8,7 +11,10 @@ interface McpRun { exitCode: number | null; } -/** Starts `ig mcp`, sends `initialize`, and closes stdin once the server answers. */ +/** + * Starts `ig mcp`, sends `initialize`, and closes stdin once the server answers. + * Kills the server and rejects if it has not exited within {@link MCP_RUN_DEADLINE_MS}. + */ function initializeMcpServer(args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [execLocation, "mcp", ...args], { stdio: ["pipe", "pipe", "pipe"] }); @@ -16,6 +22,13 @@ function initializeMcpServer(args: string[]): Promise { let response: any; let buffer = ""; + const deadline = setTimeout(() => { + child.kill(); + reject(new Error(response + ? `MCP server did not exit within ${MCP_RUN_DEADLINE_MS} ms after stdin closed` + : `MCP server did not answer initialize within ${MCP_RUN_DEADLINE_MS} ms`)); + }, MCP_RUN_DEADLINE_MS); + child.stdout.on("data", (data: Buffer) => { buffer += data.toString(); const lines = buffer.split("\n"); @@ -34,8 +47,14 @@ function initializeMcpServer(args: string[]): Promise { } } }); - child.on("error", reject); - child.on("exit", exitCode => resolve({ response, nonProtocolLines, exitCode })); + child.on("error", err => { + clearTimeout(deadline); + reject(err); + }); + child.on("exit", exitCode => { + clearTimeout(deadline); + resolve({ response, nonProtocolLines, exitCode }); + }); child.stdin.write(JSON.stringify({ jsonrpc: "2.0", @@ -53,12 +72,12 @@ describe("MCP command", () => { expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); expect(run.nonProtocolLines).toEqual([]); expect(run.exitCode).toBe(0); - }, 30000); + }, SPEC_TIMEOUT_MS); it("passes --remote= to the server", async () => { const run = await initializeMcpServer(["--remote=http://127.0.0.1:9"]); expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); expect(run.nonProtocolLines).toEqual([]); - }, 30000); + }, SPEC_TIMEOUT_MS); }); From 4449d5925ee257c5f014b7084385ee7e36709a03 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 3 Nov 2026 11:40:35 +0200 Subject: [PATCH 3/3] test(cli): check that ig mcp sends docs requests to the --remote backend The remote specs started the server against an unreachable URL and only checked the initialize reply, which local mode returns too. They now call list_components against a local HTTP fixture and assert that the fixture received the request and that its response reaches the tool result, for both --remote= and --remote . --- spec/acceptance/mcp-spec.ts | 98 ++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/spec/acceptance/mcp-spec.ts b/spec/acceptance/mcp-spec.ts index ebb1c5de27..a5672892f0 100644 --- a/spec/acceptance/mcp-spec.ts +++ b/spec/acceptance/mcp-spec.ts @@ -1,32 +1,61 @@ import { spawn } from "child_process"; +import * as http from "http"; +import { AddressInfo } from "net"; const execLocation = "packages/cli/bin/execute.js"; // shorter than the specs' Jasmine timeout, so a hung server is killed instead of outliving the spec const MCP_RUN_DEADLINE_MS = 20000; const SPEC_TIMEOUT_MS = 30000; +interface McpRequest { + method: string; + params?: unknown; +} + interface McpRun { - response: any; + /** Responses by request id: 1 is `initialize`, then one per request passed in, in order. */ + responses: Map; nonProtocolLines: string[]; exitCode: number | null; } /** - * Starts `ig mcp`, sends `initialize`, and closes stdin once the server answers. + * Starts `ig mcp`, sends `initialize`, then each request in turn after the previous one is answered, + * and closes stdin once the last is answered. * Kills the server and rejects if it has not exited within {@link MCP_RUN_DEADLINE_MS}. */ -function initializeMcpServer(args: string[]): Promise { +function runMcpSession(args: string[], requests: McpRequest[] = []): Promise { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [execLocation, "mcp", ...args], { stdio: ["pipe", "pipe", "pipe"] }); + const queue: McpRequest[] = [ + { + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcp-spec", version: "0" } } + }, + ...requests + ]; + const responses = new Map(); const nonProtocolLines: string[] = []; - let response: any; let buffer = ""; + const send = (message: object) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); + const sendNext = () => { + const id = responses.size + 1; + if (id > queue.length) { + child.stdin.end(); + return; + } + if (id === 2) { + send({ method: "notifications/initialized" }); + } + send({ id, ...queue[id - 1] }); + }; + const deadline = setTimeout(() => { child.kill(); - reject(new Error(response + reject(new Error(responses.size === queue.length ? `MCP server did not exit within ${MCP_RUN_DEADLINE_MS} ms after stdin closed` - : `MCP server did not answer initialize within ${MCP_RUN_DEADLINE_MS} ms`)); + : `MCP server did not answer ${queue[responses.size].method} within ${MCP_RUN_DEADLINE_MS} ms`)); }, MCP_RUN_DEADLINE_MS); child.stdout.on("data", (data: Buffer) => { @@ -41,9 +70,9 @@ function initializeMcpServer(args: string[]): Promise { nonProtocolLines.push(line); continue; } - if (message.id === 1) { - response = message; - child.stdin.end(); + if (message.id === responses.size + 1) { + responses.set(message.id, message); + sendNext(); } } }); @@ -53,31 +82,54 @@ function initializeMcpServer(args: string[]): Promise { }); child.on("exit", exitCode => { clearTimeout(deadline); - resolve({ response, nonProtocolLines, exitCode }); + resolve({ responses, nonProtocolLines, exitCode }); }); - child.stdin.write(JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcp-spec", version: "0" } } - }) + "\n"); + sendNext(); }); } describe("MCP command", () => { it("starts the MCP server, which answers initialize over stdio and exits when stdin closes", async () => { - const run = await initializeMcpServer([]); + const run = await runMcpSession([]); - expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); + expect(run.responses.get(1)?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); expect(run.nonProtocolLines).toEqual([]); expect(run.exitCode).toBe(0); }, SPEC_TIMEOUT_MS); - it("passes --remote= to the server", async () => { - const run = await initializeMcpServer(["--remote=http://127.0.0.1:9"]); + describe("with a remote docs backend", () => { + const fixtureIndex = "remote fixture index"; + let backend: http.Server; + let backendUrl: string; + let backendRequests: string[]; - expect(run.response?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); - expect(run.nonProtocolLines).toEqual([]); - }, SPEC_TIMEOUT_MS); + beforeEach(async () => { + backendRequests = []; + backend = http.createServer((req, res) => { + backendRequests.push(req.url ?? ""); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(fixtureIndex); + }); + await new Promise(resolve => backend.listen(0, "127.0.0.1", resolve)); + backendUrl = `http://127.0.0.1:${(backend.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await new Promise(resolve => backend.close(resolve)); + }); + + for (const remoteArgs of [(url: string) => [`--remote=${url}`], (url: string) => ["--remote", url]]) { + it(`sends docs requests to the backend given with ${remoteArgs("").join(" ")}`, async () => { + const run = await runMcpSession(remoteArgs(backendUrl), [ + { method: "tools/call", params: { name: "list_components", arguments: { framework: "angular", filter: "grid" } } } + ]); + + expect(backendRequests).toEqual(["/api/docs?framework=angular&filter=grid"]); + expect(run.responses.get(2)?.result?.content?.[0]?.text).toContain(fixtureIndex); + expect(run.nonProtocolLines).toEqual([]); + expect(run.exitCode).toBe(0); + }, SPEC_TIMEOUT_MS); + } + }); });