diff --git a/packages/cli/bin/execute.js b/packages/cli/bin/execute.js index 2333695da..758d8904b 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 a26ddf5c2..557e9b943 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 20dbaafc5..b6061e6c3 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 000000000..a5672892f --- /dev/null +++ b/spec/acceptance/mcp-spec.ts @@ -0,0 +1,135 @@ +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 { + /** 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`, 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 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 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(responses.size === queue.length + ? `MCP server did not exit within ${MCP_RUN_DEADLINE_MS} ms after stdin closed` + : `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) => { + 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 === responses.size + 1) { + responses.set(message.id, message); + sendNext(); + } + } + }); + child.on("error", err => { + clearTimeout(deadline); + reject(err); + }); + child.on("exit", exitCode => { + clearTimeout(deadline); + resolve({ responses, nonProtocolLines, exitCode }); + }); + + sendNext(); + }); +} + +describe("MCP command", () => { + it("starts the MCP server, which answers initialize over stdio and exits when stdin closes", async () => { + const run = await runMcpSession([]); + + expect(run.responses.get(1)?.result?.serverInfo?.name).toBe("igniteui-mcp-server"); + expect(run.nonProtocolLines).toEqual([]); + expect(run.exitCode).toBe(0); + }, SPEC_TIMEOUT_MS); + + describe("with a remote docs backend", () => { + const fixtureIndex = "remote fixture index"; + let backend: http.Server; + let backendUrl: string; + let backendRequests: string[]; + + 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); + } + }); +}); diff --git a/spec/unit/mcp-cli-spec.ts b/spec/unit/mcp-cli-spec.ts index bd988b396..efca1b507 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(); + }); + }); });