Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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