Skip to content

Commit b4882a0

Browse files
authored
Merge 8452fc6 into 8479dc2
2 parents 8479dc2 + 8452fc6 commit b4882a0

File tree

13 files changed

+1716
-14
lines changed

13 files changed

+1716
-14
lines changed

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
77
import vitestPlugin from "@vitest/eslint-plugin";
88
import enforceZodV4 from "./eslint-rules/enforce-zod-v4.js";
99

10-
const testFiles = ["tests/**/*.test.ts", "tests/**/*.ts"];
10+
const testFiles = ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.ts", "tests/**/*.tsx"];
1111

1212
const files = [...testFiles, "src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts"];
1313

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,13 @@
9494
"@modelcontextprotocol/inspector": "^0.17.1",
9595
"@mongodb-js/oidc-mock-provider": "^0.12.0",
9696
"@redocly/cli": "^2.0.8",
97+
"@testing-library/jest-dom": "^6.9.1",
98+
"@testing-library/react": "^16.3.0",
9799
"@types/express": "^5.0.3",
98100
"@types/node": "^24.5.2",
99101
"@types/proper-lockfile": "^4.1.4",
100102
"@types/react": "^18.3.0",
101103
"@types/react-dom": "^19.2.3",
102-
"react": "^18.3.0",
103-
"react-dom": "^18.3.0",
104104
"@types/semver": "^7.7.0",
105105
"@types/yargs-parser": "^21.0.3",
106106
"@typescript-eslint/parser": "^8.44.0",
@@ -113,6 +113,7 @@
113113
"eslint-config-prettier": "^10.1.8",
114114
"eslint-plugin-prettier": "^5.5.4",
115115
"globals": "^16.3.0",
116+
"happy-dom": "^20.0.11",
116117
"husky": "^9.1.7",
117118
"knip": "^5.63.1",
118119
"mongodb": "^6.21.0",
@@ -121,6 +122,8 @@
121122
"openapi-typescript": "^7.9.1",
122123
"prettier": "^3.6.2",
123124
"proper-lockfile": "^4.1.2",
125+
"react": "^18.3.0",
126+
"react-dom": "^18.3.0",
124127
"semver": "^7.7.2",
125128
"simple-git": "^3.28.0",
126129
"testcontainers": "^11.7.1",

pnpm-lock.yaml

Lines changed: 474 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { describe, expect, it, afterAll } from "vitest";
2+
import { describeWithMongoDB } from "../tools/mongodb/mongodbHelpers.js";
3+
import { defaultTestConfig, expectDefined, getResponseElements } from "../helpers.js";
4+
import { CompositeLogger } from "../../../src/common/logger.js";
5+
import { ExportsManager } from "../../../src/common/exportsManager.js";
6+
import { Session } from "../../../src/common/session.js";
7+
import { Telemetry } from "../../../src/telemetry/telemetry.js";
8+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9+
import { Server } from "../../../src/server.js";
10+
import { MCPConnectionManager } from "../../../src/common/connectionManager.js";
11+
import { DeviceId } from "../../../src/helpers/deviceId.js";
12+
import { connectionErrorHandler } from "../../../src/common/connectionErrorHandler.js";
13+
import { Keychain } from "../../../src/common/keychain.js";
14+
import { Elicitation } from "../../../src/elicitation.js";
15+
import { VectorSearchEmbeddingsManager } from "../../../src/common/search/vectorSearchEmbeddingsManager.js";
16+
import { defaultCreateAtlasLocalClient } from "../../../src/common/atlasLocal.js";
17+
import { InMemoryTransport } from "../../../src/transports/inMemoryTransport.js";
18+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
19+
20+
describeWithMongoDB(
21+
"mcpUI feature with feature disabled (default)",
22+
(integration) => {
23+
describe("list-databases tool", () => {
24+
it("should NOT return UIResource content when mcpUI feature is disabled", async () => {
25+
await integration.connectMcpClient();
26+
const response = await integration.mcpClient().callTool({
27+
name: "list-databases",
28+
arguments: {},
29+
});
30+
31+
expect(response.content).toBeDefined();
32+
expect(Array.isArray(response.content)).toBe(true);
33+
34+
const elements = response.content as Array<{ type: string }>;
35+
const resourceElements = elements.filter((e) => e.type === "resource");
36+
expect(resourceElements).toHaveLength(0);
37+
38+
const textElements = getResponseElements(response.content);
39+
expect(textElements.length).toBeGreaterThan(0);
40+
});
41+
});
42+
},
43+
{
44+
getUserConfig: () => ({
45+
...defaultTestConfig,
46+
previewFeatures: [], // mcpUI is NOT enabled
47+
}),
48+
}
49+
);
50+
51+
describeWithMongoDB(
52+
"mcpUI feature with feature enabled",
53+
(integration) => {
54+
describe("list-databases tool", () => {
55+
it("should return UIResource content when mcpUI feature is enabled", async () => {
56+
await integration.connectMcpClient();
57+
const response = await integration.mcpClient().callTool({
58+
name: "list-databases",
59+
arguments: {},
60+
});
61+
62+
expect(response.content).toBeDefined();
63+
expect(Array.isArray(response.content)).toBe(true);
64+
65+
const elements = response.content as Array<{ type: string; resource?: unknown }>;
66+
67+
const textElements = elements.filter((e) => e.type === "text");
68+
expect(textElements.length).toBeGreaterThan(0);
69+
70+
const resourceElements = elements.filter((e) => e.type === "resource");
71+
expect(resourceElements).toHaveLength(1);
72+
73+
const uiResource = resourceElements[0] as {
74+
type: string;
75+
resource: {
76+
uri: string;
77+
mimeType: string;
78+
text: string;
79+
_meta?: Record<string, unknown>;
80+
};
81+
};
82+
83+
expect(uiResource.type).toBe("resource");
84+
expectDefined(uiResource.resource);
85+
expect(uiResource.resource.uri).toMatch(/^ui:\/\/list-databases\/\d+$/);
86+
expect(uiResource.resource.mimeType).toBe("text/html");
87+
expect(typeof uiResource.resource.text).toBe("string");
88+
expect(uiResource.resource.text.length).toBeGreaterThan(0);
89+
90+
expectDefined(uiResource.resource._meta);
91+
expect(uiResource.resource._meta["mcpui.dev/ui-initial-render-data"]).toBeDefined();
92+
93+
const renderData = uiResource.resource._meta["mcpui.dev/ui-initial-render-data"] as {
94+
databases: Array<{ name: string; size: number }>;
95+
totalCount: number;
96+
};
97+
expect(renderData.databases).toBeInstanceOf(Array);
98+
expect(typeof renderData.totalCount).toBe("number");
99+
expect(renderData.totalCount).toBe(renderData.databases.length);
100+
101+
for (const db of renderData.databases) {
102+
expect(typeof db.name).toBe("string");
103+
expect(typeof db.size).toBe("number");
104+
}
105+
});
106+
107+
it("should include system databases in the response", async () => {
108+
await integration.connectMcpClient();
109+
const response = await integration.mcpClient().callTool({
110+
name: "list-databases",
111+
arguments: {},
112+
});
113+
114+
const elements = response.content as Array<{
115+
type: string;
116+
resource?: { _meta?: Record<string, unknown> };
117+
}>;
118+
const resourceElement = elements.find((e) => e.type === "resource");
119+
expectDefined(resourceElement);
120+
121+
const renderData = resourceElement.resource?._meta?.["mcpui.dev/ui-initial-render-data"] as {
122+
databases: Array<{ name: string; size: number }>;
123+
};
124+
125+
const dbNames = renderData.databases.map((db) => db.name);
126+
127+
expect(dbNames).toContain("admin");
128+
expect(dbNames).toContain("local");
129+
});
130+
});
131+
},
132+
{
133+
getUserConfig: () => ({
134+
...defaultTestConfig,
135+
previewFeatures: ["mcpUI"], // mcpUI IS enabled
136+
}),
137+
}
138+
);
139+
140+
describeWithMongoDB(
141+
"mcpUI feature - UIRegistry initialization",
142+
(integration) => {
143+
describe("server UIRegistry", () => {
144+
it("should have UIRegistry initialized with bundled UIs", () => {
145+
const server = integration.mcpServer();
146+
expectDefined(server.uiRegistry);
147+
148+
expect(server.uiRegistry.has("list-databases")).toBe(true);
149+
150+
const uiHtml = server.uiRegistry.get("list-databases");
151+
expectDefined(uiHtml);
152+
expect(uiHtml.length).toBeGreaterThan(0);
153+
});
154+
155+
it("should return list of available tools with UIs", () => {
156+
const server = integration.mcpServer();
157+
const availableTools = server.uiRegistry.getAvailableTools();
158+
159+
expect(Array.isArray(availableTools)).toBe(true);
160+
expect(availableTools).toContain("list-databases");
161+
});
162+
});
163+
},
164+
{
165+
getUserConfig: () => ({
166+
...defaultTestConfig,
167+
previewFeatures: ["mcpUI"],
168+
}),
169+
}
170+
);
171+
172+
describe("mcpUI feature with custom UIs", () => {
173+
const initServerWithCustomUIs = async (
174+
customUIs: Record<string, string>
175+
): Promise<{ server: Server; transport: Transport }> => {
176+
const userConfig = {
177+
...defaultTestConfig,
178+
previewFeatures: ["mcpUI" as const],
179+
};
180+
const logger = new CompositeLogger();
181+
const deviceId = DeviceId.create(logger);
182+
const connectionManager = new MCPConnectionManager(userConfig, logger, deviceId);
183+
const exportsManager = ExportsManager.init(userConfig, logger);
184+
185+
const session = new Session({
186+
userConfig,
187+
logger,
188+
exportsManager,
189+
connectionManager,
190+
keychain: Keychain.root,
191+
vectorSearchEmbeddingsManager: new VectorSearchEmbeddingsManager(userConfig, connectionManager),
192+
atlasLocalClient: await defaultCreateAtlasLocalClient(),
193+
});
194+
195+
const telemetry = Telemetry.create(session, userConfig, deviceId);
196+
const mcpServerInstance = new McpServer({ name: "test", version: "1.0" });
197+
const elicitation = new Elicitation({ server: mcpServerInstance.server });
198+
199+
const server = new Server({
200+
session,
201+
userConfig,
202+
telemetry,
203+
mcpServer: mcpServerInstance,
204+
elicitation,
205+
connectionErrorHandler,
206+
customUIs,
207+
});
208+
209+
const transport = new InMemoryTransport();
210+
211+
return { transport, server };
212+
};
213+
214+
let server: Server | undefined;
215+
let transport: Transport | undefined;
216+
217+
afterAll(async () => {
218+
await transport?.close();
219+
await server?.close();
220+
});
221+
222+
it("should use custom UI when provided via server options", async () => {
223+
const customUIs = {
224+
"list-databases": "<html>Custom Test UI</html>",
225+
};
226+
227+
({ server, transport } = await initServerWithCustomUIs(customUIs));
228+
await server.connect(transport);
229+
230+
expectDefined(server.uiRegistry);
231+
expect(server.uiRegistry.has("list-databases")).toBe(true);
232+
expect(server.uiRegistry.get("list-databases")).toBe("<html>Custom Test UI</html>");
233+
});
234+
235+
it("should add new custom UIs for tools without bundled UIs", async () => {
236+
const customUIs = {
237+
"custom-tool": "<html>Custom Tool UI</html>",
238+
};
239+
240+
({ server, transport } = await initServerWithCustomUIs(customUIs));
241+
await server.connect(transport);
242+
243+
expectDefined(server.uiRegistry);
244+
expect(server.uiRegistry.has("custom-tool")).toBe(true);
245+
expect(server.uiRegistry.get("custom-tool")).toBe("<html>Custom Tool UI</html>");
246+
});
247+
248+
it("should merge custom UIs with bundled UIs", async () => {
249+
const customUIs = {
250+
"new-tool": "<html>New Tool UI</html>",
251+
};
252+
253+
({ server, transport } = await initServerWithCustomUIs(customUIs));
254+
await server.connect(transport);
255+
256+
expectDefined(server.uiRegistry);
257+
258+
expect(server.uiRegistry.has("new-tool")).toBe(true);
259+
expect(server.uiRegistry.get("new-tool")).toBe("<html>New Tool UI</html>");
260+
261+
expect(server.uiRegistry.has("list-databases")).toBe(true);
262+
const bundledUI = server.uiRegistry.get("list-databases");
263+
expectDefined(bundledUI);
264+
expect(bundledUI.length).toBeGreaterThan(0);
265+
});
266+
});

tests/setupReact.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import "@testing-library/jest-dom/vitest";
2+

0 commit comments

Comments
 (0)