Skip to content

Commit ecb7a54

Browse files
Merge pull request #1572 from IgniteUI/ganastasov/igniteui-mcp-unit-tests
test(igniteui-mcp): add MCP cli and runtime unit tests
2 parents 5211431 + 88ac4e7 commit ecb7a54

4 files changed

Lines changed: 1102 additions & 46 deletions

File tree

packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { appendFileSync, mkdirSync } from "fs";
66
import { join, dirname } from "path";
77
import { fileURLToPath } from "url";
88
import dotenv from "dotenv";
9-
import { TOOL_DESCRIPTIONS, SETUP_DOCS, SETUP_MD, BLAZOR_DOTNET_GUIDE, USAGE_GUIDE } from "./tools/constants.js";
9+
import { TOOL_DESCRIPTIONS, USAGE_GUIDE } from "./tools/constants.js";
1010
import type { DocsProvider } from "./providers/DocsProvider.js";
1111
import { RemoteDocsProvider } from "./providers/RemoteDocsProvider.js";
1212
import { LocalDocsProvider } from "./providers/LocalDocsProvider.js";
1313
import { getApiReferenceSchema, searchApiSchema } from "./tools/schemas.js";
1414
import { createGetApiReferenceHandler, createSearchApiHandler } from "./tools/handlers.js";
15+
import { buildProjectSetupGuide, sanitizeSearchDocsQuery } from "./tools/doc-tools.js";
1516
import { ApiDocLoader } from "./lib/api-doc-loader.js";
1617
import { getPlatforms } from "./config/platforms.js";
1718

@@ -170,30 +171,7 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) {
170171
return { content: [{ type: "text" as const, text: "Empty query." }] };
171172
}
172173

173-
// Sanitize user input for FTS4 MATCH syntax.
174-
// Strip characters that are FTS4 operators or cause syntax errors:
175-
// " (phrase delimiter), ( ) (grouping), : (column filter), @ (internal)
176-
// Preserve hyphens — the porter tokenizer handles them consistently
177-
// at both index and query time (e.g. "grid-editing" stays as one phrase).
178-
// Preserve trailing * — FTS4 prefix queries (e.g. grid*) rely on it,
179-
// and the DB is built with prefix="2,3" indexes to support this.
180-
const sanitized = queryText
181-
.replace(/["(){}[\]:@]/g, " ")
182-
.split(/\s+/)
183-
.filter(Boolean)
184-
.map((term) => {
185-
// Terms ending with * are prefix queries — don't quote them
186-
// because FTS4 treats "grid*" as a literal match for the
187-
// asterisk character, while unquoted grid* does prefix expansion.
188-
// Drop terms that are only asterisks (e.g. *, **) — they have
189-
// no actual prefix and would cause an FTS4 syntax error.
190-
if (term.endsWith("*")) {
191-
return /[^*]/.test(term) ? term : null;
192-
}
193-
return `"${term}"`;
194-
})
195-
.filter(Boolean)
196-
.join(" OR ");
174+
const sanitized = sanitizeSearchDocsQuery(queryText);
197175

198176
if (!sanitized) {
199177
log("search_docs", { query: queryText, framework }, "Empty query after sanitization.", 0);
@@ -223,27 +201,7 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) {
223201
async ({ framework }) => {
224202
const start = performance.now();
225203

226-
if (!framework) {
227-
const msg = "Which framework are you using? Please specify one of: angular, react, blazor, or webcomponents.";
228-
log("get_project_setup_guide", {}, msg, 0);
229-
return { content: [{ type: "text" as const, text: msg }] };
230-
}
231-
232-
let result: string;
233-
234-
if (framework === "blazor") {
235-
const docNames = SETUP_DOCS["blazor"] || [];
236-
const sections: string[] = [BLAZOR_DOTNET_GUIDE];
237-
for (const name of docNames) {
238-
const { text, found } = await docsProvider.getDoc(framework, name);
239-
if (found) {
240-
sections.push(text);
241-
}
242-
}
243-
result = sections.join("\n\n---\n\n");
244-
} else {
245-
result = SETUP_MD[framework] ?? `No setup guide available for framework: ${framework}`;
246-
}
204+
const result = await buildProjectSetupGuide(docsProvider, framework);
247205

248206
log("get_project_setup_guide", { framework }, result, Math.round(performance.now() - start));
249207
return { content: [{ type: "text" as const, text: result }] };
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { DocsProvider } from '../providers/DocsProvider.js';
2+
import { BLAZOR_DOTNET_GUIDE, SETUP_DOCS, SETUP_MD } from './constants.js';
3+
4+
export const MISSING_FRAMEWORK_MESSAGE =
5+
'Which framework are you using? Please specify one of: angular, react, blazor, or webcomponents.';
6+
7+
// Sanitize user input for FTS4 MATCH syntax.
8+
// Strip characters that are FTS4 operators or commonly cause syntax issues:
9+
// " (phrase delimiter), ( ) (grouping), { } [ ] (extra grouping/bracketing),
10+
// : (column filter), @ (internal)
11+
// Preserve hyphens — the porter tokenizer handles them consistently
12+
// at both index and query time (e.g. "grid-editing" stays as one phrase).
13+
// Preserve trailing * — FTS4 prefix queries (e.g. grid*) rely on it,
14+
// and the DB is built with prefix="2,3" indexes to support this.
15+
export function sanitizeSearchDocsQuery(queryText: string): string | null {
16+
const sanitized = queryText
17+
.replace(/["(){}[\]:@]/g, ' ')
18+
.split(/\s+/)
19+
.filter(Boolean)
20+
.map((term) => {
21+
// Terms ending with * are prefix queries — don't quote them
22+
// because FTS4 treats "grid*" as a literal match for the
23+
// asterisk character, while unquoted grid* does prefix expansion.
24+
// Drop terms that are only asterisks (e.g. *, **) — they have
25+
// no actual prefix and would cause an FTS4 syntax error.
26+
if (term.endsWith('*')) {
27+
return /[^*]/.test(term) ? term : null;
28+
}
29+
30+
return `"${term}"`;
31+
})
32+
.filter((term): term is string => Boolean(term))
33+
.join(' OR ');
34+
35+
return sanitized || null;
36+
}
37+
38+
// Build the setup-guide response for the requested framework.
39+
// For Blazor, combine the base .NET guide with any MCP-fetched docs
40+
// that are available for the configured setup document names.
41+
// For other frameworks, return the static setup markdown when present,
42+
// otherwise fall back to a simple "not available" message.
43+
export async function buildProjectSetupGuide(
44+
docsProvider: DocsProvider,
45+
framework?: string,
46+
): Promise<string> {
47+
if (!framework) {
48+
return MISSING_FRAMEWORK_MESSAGE;
49+
}
50+
51+
if (framework === 'blazor') {
52+
const docNames = SETUP_DOCS.blazor || [];
53+
const sections: string[] = [BLAZOR_DOTNET_GUIDE];
54+
55+
for (const name of docNames) {
56+
const { text, found } = await docsProvider.getDoc(framework, name);
57+
if (found) {
58+
sections.push(text);
59+
}
60+
}
61+
62+
return sections.join('\n\n---\n\n');
63+
}
64+
65+
return (
66+
SETUP_MD[framework] ??
67+
`No setup guide available for framework: ${framework}`
68+
);
69+
}

spec/unit/mcp-cli-spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import child_process from "child_process";
2+
import { EventEmitter } from "events";
3+
import fs = require("fs");
4+
import * as path from "path";
5+
import yargs from "yargs";
6+
import mcp from "../../packages/cli/lib/commands/mcp";
7+
8+
function createFakeChildProcess(): EventEmitter {
9+
return new EventEmitter();
10+
}
11+
12+
describe("Unit - MCP CLI command", () => {
13+
const mcpPackageJson = path.join(process.cwd(), "node_modules", "@igniteui", "mcp-server", "package.json");
14+
const mcpEntry = path.resolve(path.dirname(mcpPackageJson), "dist", "index.js");
15+
16+
let stderrWriteSpy: jasmine.Spy;
17+
let spawnSpy: jasmine.Spy;
18+
19+
beforeEach(() => {
20+
process.exitCode = undefined;
21+
stderrWriteSpy = spyOn(process.stderr, "write").and.returnValue(true);
22+
spawnSpy = spyOn(child_process, "spawn");
23+
});
24+
25+
afterEach(() => {
26+
process.exitCode = undefined;
27+
});
28+
29+
function mockMcpPackageResolution(resolvedPath?: string, shouldThrow = false): void {
30+
const moduleApi = require("module");
31+
const originalResolveFilename = moduleApi._resolveFilename;
32+
33+
spyOn(moduleApi, "_resolveFilename").and.callFake((request: string, ...args: any[]) => {
34+
if (request === "@igniteui/mcp-server/package.json") {
35+
if (shouldThrow) {
36+
throw new Error("Cannot find module");
37+
}
38+
return resolvedPath;
39+
}
40+
41+
return originalResolveFilename.call(moduleApi, request, ...args);
42+
});
43+
}
44+
45+
function mockInstalledMcp(entryExists: boolean, child?: EventEmitter): EventEmitter {
46+
const spawnedChild = child ?? createFakeChildProcess();
47+
mockMcpPackageResolution(mcpPackageJson);
48+
spyOn(fs, "existsSync").and.returnValue(entryExists);
49+
spawnSpy.and.returnValue(spawnedChild as any);
50+
return spawnedChild;
51+
}
52+
53+
describe("metadata", () => {
54+
it("registers the MCP command with the expected description", () => {
55+
expect(mcp.command).toBe("mcp");
56+
expect(mcp.describe).toBe("Starts the Ignite UI MCP server for AI assistant integration");
57+
});
58+
59+
it("configures the debug and remote options", () => {
60+
const buildParser = mcp.builder as any;
61+
const parser = buildParser(yargs([]));
62+
const argv = parser.parseSync(["--remote", "https://docs.example.test", "--debug"]);
63+
const defaults = buildParser(yargs([])).parseSync([]);
64+
65+
expect(argv.remote).toBe("https://docs.example.test");
66+
expect(argv.debug).toBeTrue();
67+
expect(defaults.debug).toBeFalse();
68+
});
69+
});
70+
71+
describe("preflight checks", () => {
72+
it("shows an install message when the MCP server package cannot be resolved", async () => {
73+
const existsSyncSpy = spyOn(fs, "existsSync");
74+
mockMcpPackageResolution(undefined, true);
75+
76+
await mcp.handler({ _: ["mcp"], $0: "ig" } as any);
77+
78+
expect(process.exitCode).toBe(1);
79+
expect(existsSyncSpy).not.toHaveBeenCalled();
80+
expect(spawnSpy).not.toHaveBeenCalled();
81+
82+
expect(stderrWriteSpy).toHaveBeenCalled();
83+
const message = stderrWriteSpy.calls.allArgs().map(args => args[0]).join("");
84+
expect(message).toContain("MCP server package not found");
85+
expect(message).toContain("yarn install");
86+
});
87+
88+
it("shows a build message when the MCP server entry does not exist", async () => {
89+
mockMcpPackageResolution(mcpPackageJson);
90+
spyOn(fs, "existsSync").and.returnValue(false);
91+
92+
await mcp.handler({ _: ["mcp"], $0: "ig" } as any);
93+
94+
expect(fs.existsSync).toHaveBeenCalledWith(mcpEntry);
95+
expect(process.exitCode).toBe(1);
96+
expect(spawnSpy).not.toHaveBeenCalled();
97+
98+
expect(stderrWriteSpy).toHaveBeenCalled();
99+
const message = stderrWriteSpy.calls.allArgs().map(args => args[0]).join("");
100+
expect(message).toContain("MCP server not built");
101+
expect(message).toContain("build:mcp");
102+
});
103+
});
104+
105+
describe("runtime behavior", () => {
106+
it("starts the installed MCP server with stdio inheritance", async () => {
107+
const child = mockInstalledMcp(true);
108+
const result = mcp.handler({ _: ["mcp"], $0: "ig" } as any) as Promise<void>;
109+
110+
expect(spawnSpy).toHaveBeenCalledWith(
111+
process.execPath,
112+
[mcpEntry],
113+
{ stdio: "inherit" }
114+
);
115+
116+
child.emit("exit", 0);
117+
await result;
118+
});
119+
120+
it("forwards remote and debug flags to the installed MCP server", async () => {
121+
const remoteUrl = "https://docs.example.test";
122+
const child = mockInstalledMcp(true);
123+
const result = mcp.handler({
124+
remote: remoteUrl,
125+
debug: true,
126+
_: ["mcp"],
127+
$0: "ig"
128+
} as any) as Promise<void>;
129+
130+
expect(spawnSpy).toHaveBeenCalledWith(
131+
process.execPath,
132+
[mcpEntry, "--remote", remoteUrl, "--debug"],
133+
{ stdio: "inherit" }
134+
);
135+
136+
child.emit("exit", 0);
137+
await result;
138+
});
139+
140+
it("forwards only the debug flag when remote mode is not used", async () => {
141+
const child = mockInstalledMcp(true);
142+
const result = mcp.handler({
143+
debug: true,
144+
_: ["mcp"],
145+
$0: "ig"
146+
} as any) as Promise<void>;
147+
148+
expect(spawnSpy).toHaveBeenCalledWith(
149+
process.execPath,
150+
[mcpEntry, "--debug"],
151+
{ stdio: "inherit" }
152+
);
153+
154+
child.emit("exit", 0);
155+
await result;
156+
});
157+
158+
it("propagates the child process exit code", async () => {
159+
const child = mockInstalledMcp(true);
160+
const result = mcp.handler({ _: ["mcp"], $0: "ig" } as any) as Promise<void>;
161+
162+
child.emit("exit", 7);
163+
await result;
164+
165+
expect(process.exitCode).toBe(7);
166+
});
167+
168+
it("defaults the process exit code to 0 when the child exits without one", async () => {
169+
const child = mockInstalledMcp(true);
170+
const result = mcp.handler({ _: ["mcp"], $0: "ig" } as any) as Promise<void>;
171+
172+
child.emit("exit", null);
173+
await result;
174+
175+
expect(process.exitCode).toBe(0);
176+
});
177+
178+
it("reports child process startup errors", async () => {
179+
const child = mockInstalledMcp(true);
180+
const error = new Error("boom");
181+
const result = mcp.handler({ _: ["mcp"], $0: "ig" } as any) as Promise<void>;
182+
183+
child.emit("error", error);
184+
185+
await expectAsync(result).toBeRejectedWith(error);
186+
187+
expect(stderrWriteSpy).toHaveBeenCalled();
188+
const message = stderrWriteSpy.calls.allArgs().map(args => args[0]).join("");
189+
expect(message).toContain("Failed to start MCP server");
190+
expect(message).toContain("boom");
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)