Skip to content
Open
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
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import vitestPlugin from "@vitest/eslint-plugin";
import enforceZodV4 from "./eslint-rules/enforce-zod-v4.js";

const testFiles = ["tests/**/*.test.ts", "tests/**/*.ts"];
const testFiles = ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.ts", "tests/**/*.tsx"];

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

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@
"@modelcontextprotocol/inspector": "^0.17.1",
"@mongodb-js/oidc-mock-provider": "^0.12.0",
"@redocly/cli": "^2.0.8",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/express": "^5.0.3",
"@types/node": "^24.5.2",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^19.2.3",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@types/semver": "^7.7.0",
"@types/yargs-parser": "^21.0.3",
"@typescript-eslint/parser": "^8.44.0",
Expand All @@ -115,6 +115,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.3.0",
"happy-dom": "^20.0.11",
"husky": "^9.1.7",
"knip": "^5.63.1",
"mongodb": "^6.21.0",
Expand All @@ -123,6 +124,8 @@
"openapi-typescript": "^7.9.1",
"prettier": "^3.6.2",
"proper-lockfile": "^4.1.2",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"semver": "^7.7.2",
"simple-git": "^3.28.0",
"testcontainers": "^11.7.1",
Expand Down
482 changes: 474 additions & 8 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

261 changes: 261 additions & 0 deletions tests/integration/ui/mcpUIFeature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { describe, expect, it, afterAll } from "vitest";
import { describeWithMongoDB } from "../tools/mongodb/mongodbHelpers.js";
import { defaultTestConfig, expectDefined, getResponseElements } from "../helpers.js";
import { CompositeLogger } from "../../../src/common/logger.js";
import { ExportsManager } from "../../../src/common/exportsManager.js";
import { Session } from "../../../src/common/session.js";
import { Telemetry } from "../../../src/telemetry/telemetry.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Server } from "../../../src/server.js";
import { MCPConnectionManager } from "../../../src/common/connectionManager.js";
import { DeviceId } from "../../../src/helpers/deviceId.js";
import { connectionErrorHandler } from "../../../src/common/connectionErrorHandler.js";
import { Keychain } from "../../../src/common/keychain.js";
import { Elicitation } from "../../../src/elicitation.js";
import { VectorSearchEmbeddingsManager } from "../../../src/common/search/vectorSearchEmbeddingsManager.js";
import { defaultCreateAtlasLocalClient } from "../../../src/common/atlasLocal.js";
import { InMemoryTransport } from "../../../src/transports/inMemoryTransport.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";

describeWithMongoDB(
"mcpUI feature with feature disabled (default)",
(integration) => {
describe("list-databases tool", () => {
it("should NOT return UIResource content when mcpUI feature is disabled", async () => {
await integration.connectMcpClient();
const response = await integration.mcpClient().callTool({
name: "list-databases",
arguments: {},
});

expect(response.content).toBeDefined();
expect(Array.isArray(response.content)).toBe(true);

const elements = response.content as Array<{ type: string }>;
const resourceElements = elements.filter((e) => e.type === "resource");
expect(resourceElements).toHaveLength(0);

const textElements = getResponseElements(response.content);
expect(textElements.length).toBeGreaterThan(0);
});
});
},
{
getUserConfig: () => ({
...defaultTestConfig,
previewFeatures: [], // mcpUI is NOT enabled
}),
}
);

describeWithMongoDB(
"mcpUI feature with feature enabled",
(integration) => {
describe("list-databases tool with mcpUI enabled", () => {
it("should return UIResource content when mcpUI feature is enabled", async () => {
await integration.connectMcpClient();
const response = await integration.mcpClient().callTool({
name: "list-databases",
arguments: {},
});

expect(response.content).toBeDefined();
expect(Array.isArray(response.content)).toBe(true);

const elements = response.content as Array<{ type: string; resource?: unknown }>;

const textElements = elements.filter((e) => e.type === "text");
expect(textElements.length).toBeGreaterThan(0);

const resourceElements = elements.filter((e) => e.type === "resource");
expect(resourceElements).toHaveLength(1);

const uiResource = resourceElements[0] as {
type: string;
resource: {
uri: string;
mimeType: string;
text: string;
_meta?: Record<string, unknown>;
};
};

expect(uiResource.type).toBe("resource");
expectDefined(uiResource.resource);
expect(uiResource.resource.uri).toBe("ui://list-databases");
expect(uiResource.resource.mimeType).toBe("text/html");
expect(typeof uiResource.resource.text).toBe("string");
expect(uiResource.resource.text.length).toBeGreaterThan(0);

expectDefined(uiResource.resource._meta);
expect(uiResource.resource._meta["mcpui.dev/ui-initial-render-data"]).toBeDefined();

const renderData = uiResource.resource._meta["mcpui.dev/ui-initial-render-data"] as {
databases: Array<{ name: string; size: number }>;
totalCount: number;
};
expect(renderData.databases).toBeInstanceOf(Array);
expect(typeof renderData.totalCount).toBe("number");
expect(renderData.totalCount).toBe(renderData.databases.length);

for (const db of renderData.databases) {
expect(typeof db.name).toBe("string");
expect(typeof db.size).toBe("number");
}
});

it("should include system databases in the response", async () => {
await integration.connectMcpClient();
const response = await integration.mcpClient().callTool({
name: "list-databases",
arguments: {},
});

const elements = response.content as Array<{
type: string;
resource?: { _meta?: Record<string, unknown> };
}>;
const resourceElement = elements.find((e) => e.type === "resource");
expectDefined(resourceElement);

const renderData = resourceElement.resource?._meta?.["mcpui.dev/ui-initial-render-data"] as {
databases: Array<{ name: string; size: number }>;
};

const dbNames = renderData.databases.map((db) => db.name);

expect(dbNames).toContain("admin");
expect(dbNames).toContain("local");
});
});
},
{
getUserConfig: () => ({
...defaultTestConfig,
previewFeatures: ["mcpUI"], // mcpUI IS enabled
}),
}
);

describeWithMongoDB(
"mcpUI feature - UIRegistry initialization",
(integration) => {
describe("server UIRegistry", () => {
it("should have UIRegistry initialized with bundled UIs", async () => {
const server = integration.mcpServer();
expectDefined(server.uiRegistry);

const uiHtml = await server.uiRegistry.get("list-databases");
expectDefined(uiHtml);
expect(uiHtml).not.toBeNull();
expect(uiHtml.length).toBeGreaterThan(0);
});
});
},
{
getUserConfig: () => ({
...defaultTestConfig,
previewFeatures: ["mcpUI"],
}),
}
);

describe("mcpUI feature with custom UIs", () => {
const initServerWithCustomUIs = async (
customUIs: Record<string, string>
): Promise<{ server: Server; transport: Transport }> => {
const customUIsFunction = (toolName: string): string | null => customUIs[toolName] ?? null;
const userConfig = {
...defaultTestConfig,
previewFeatures: ["mcpUI" as const],
};
const logger = new CompositeLogger();
const deviceId = DeviceId.create(logger);
const connectionManager = new MCPConnectionManager(userConfig, logger, deviceId);
const exportsManager = ExportsManager.init(userConfig, logger);

const session = new Session({
userConfig,
logger,
exportsManager,
connectionManager,
keychain: Keychain.root,
vectorSearchEmbeddingsManager: new VectorSearchEmbeddingsManager(userConfig, connectionManager),
atlasLocalClient: await defaultCreateAtlasLocalClient(),
});

const telemetry = Telemetry.create(session, userConfig, deviceId);
const mcpServerInstance = new McpServer({ name: "test", version: "1.0" });
const elicitation = new Elicitation({ server: mcpServerInstance.server });

const server = new Server({
session,
userConfig,
telemetry,
mcpServer: mcpServerInstance,
elicitation,
connectionErrorHandler,
customUIs: customUIsFunction,
});

const transport = new InMemoryTransport();

return { transport, server };
};

let server: Server | undefined;
let transport: Transport | undefined;

afterAll(async () => {
await transport?.close();
await server?.close();
});

it("should use custom UI when provided via server options", async () => {
const customUIs = {
"list-databases": "<html>Custom Test UI</html>",
};

({ server, transport } = await initServerWithCustomUIs(customUIs));
await server.connect(transport);

expectDefined(server.uiRegistry);
const uiHtml = await server.uiRegistry.get("list-databases");
expectDefined(uiHtml);
expect(uiHtml).toBe("<html>Custom Test UI</html>");
});

it("should add new custom UIs for tools without bundled UIs", async () => {
const customUIs = {
"custom-tool": "<html>Custom Tool UI</html>",
};

({ server, transport } = await initServerWithCustomUIs(customUIs));
await server.connect(transport);

expectDefined(server.uiRegistry);
const uiHtml = await server.uiRegistry.get("custom-tool");
expectDefined(uiHtml);
expect(uiHtml).toBe("<html>Custom Tool UI</html>");
});

it("should merge custom UIs with bundled UIs", async () => {
const customUIs = {
"new-tool": "<html>New Tool UI</html>",
};

({ server, transport } = await initServerWithCustomUIs(customUIs));
await server.connect(transport);

expectDefined(server.uiRegistry);

const newToolUI = await server.uiRegistry.get("new-tool");
expectDefined(newToolUI);
expect(newToolUI).toBe("<html>New Tool UI</html>");

const bundledUI = await server.uiRegistry.get("list-databases");
expectDefined(bundledUI);
expect(bundledUI).not.toBeNull();
expect(bundledUI.length).toBeGreaterThan(0);
});
});
1 change: 1 addition & 0 deletions tests/setupReact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
Loading