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
41 changes: 32 additions & 9 deletions packages/cli/bin/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
37 changes: 37 additions & 0 deletions packages/cli/lib/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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",
Expand Down
18 changes: 18 additions & 0 deletions spec/acceptance/help-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
135 changes: 135 additions & 0 deletions spec/acceptance/mcp-spec.ts
Original file line number Diff line number Diff line change
@@ -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<number, any>;
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<McpRun> {
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<number, any>();
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<void>(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("<url>").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);
}
});
});
46 changes: 45 additions & 1 deletion spec/unit/mcp-cli-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 <url> and --debug through", () => {
expect(getMcpServerArgs(["--remote", "https://docs.example.test", "--debug"]))
.toEqual(["--remote", "https://docs.example.test", "--debug"]);
});

it("splits --remote=<url>, 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();
});
});
});
Loading