diff --git a/README.md b/README.md index f8ea9afd..9de6572e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Model Context Protocol server for interacting with MongoDB Databases and Mongo - [🛠️ Supported Tools](#supported-tools) - [MongoDB Atlas Tools](#mongodb-atlas-tools) - [MongoDB Database Tools](#mongodb-database-tools) + - [MongoDB Assistant Tools](#mongodb-assistant-tools) - [📄 Supported Resources](#supported-resources) - [⚙️ Configuration](#configuration) - [Configuration Options](#configuration-options) @@ -320,6 +321,11 @@ NOTE: atlas tools are only available when you set credentials on [configuration] - `db-stats` - Return statistics about a MongoDB database - `export` - Export query or aggregation results to EJSON format. Creates a uniquely named export accessible via the `exported-data` resource. +#### MongoDB Assistant Tools + +- `list-knowledge-sources` - List available data sources in the MongoDB Assistant knowledge base. Example sources include various MongoDB documentation sites, MongoDB University courses, and other useful learning resources. +- `search-knowledge` - Search for information in the MongoDB Assistant knowledge base. + ## 📄 Supported Resources - `config` - Server configuration, supplied by the user either as environment variables or as startup arguments with sensitive parameters redacted. The resource can be accessed under URI `config://config`. diff --git a/package-lock.json b/package-lock.json index 66405c53..1d9d9f49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", "ts-levenshtein": "^1.0.7", + "yaml": "^2.8.1", "yargs-parser": "^22.0.0", "zod": "^3.25.76" }, @@ -15363,10 +15364,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index e689ea9c..f2a906db 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/yargs-parser": "^21.0.3", "@typescript-eslint/parser": "^8.44.0", "@vitest/coverage-v8": "^3.2.4", + "@vitest/eslint-plugin": "^1.3.4", "ai": "^4.3.17", "duplexpair": "^1.0.2", "eslint": "^9.34.0", @@ -92,7 +93,6 @@ "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint": "^8.41.0", - "@vitest/eslint-plugin": "^1.3.4", "uuid": "^13.0.0", "vitest": "^3.2.4" }, @@ -114,6 +114,7 @@ "oauth4webapi": "^3.8.0", "openapi-fetch": "^0.14.0", "ts-levenshtein": "^1.0.7", + "yaml": "^2.8.1", "yargs-parser": "^22.0.0", "zod": "^3.25.76" }, diff --git a/src/common/config.ts b/src/common/config.ts index cbac900c..0958c736 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -160,6 +160,7 @@ export interface UserConfig extends CliOptions { apiBaseUrl: string; apiClientId?: string; apiClientSecret?: string; + assistantBaseUrl: string; telemetry: "enabled" | "disabled"; logPath: string; exportsPath: string; @@ -185,6 +186,7 @@ export interface UserConfig extends CliOptions { export const defaultUserConfig: UserConfig = { apiBaseUrl: "https://cloud.mongodb.com/", + assistantBaseUrl: "https://knowledge.mongodb.com/api/v1/", logPath: getLogPath(), exportsPath: getExportsPath(), exportTimeoutMs: 5 * 60 * 1000, // 5 minutes diff --git a/src/common/logger.ts b/src/common/logger.ts index 07b126aa..8f3e02b5 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -70,6 +70,9 @@ export const LogId = { exportLockError: mongoLogId(1_007_008), oidcFlow: mongoLogId(1_008_001), + + assistantListKnowledgeSourcesError: mongoLogId(1_009_001), + assistantSearchKnowledgeError: mongoLogId(1_009_002), } as const; export interface LogPayload { diff --git a/src/server.ts b/src/server.ts index 458bcd28..f8ce8190 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import assert from "assert"; import type { ToolBase } from "./tools/tool.js"; +import { AssistantTools } from "./tools/assistant/tools.js"; import { validateConnectionString } from "./helpers/connectionOptions.js"; import { packageInfo } from "./common/packageInfo.js"; import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js"; @@ -206,7 +207,7 @@ export class Server { } private registerTools(): void { - for (const toolConstructor of [...AtlasTools, ...MongoDbTools]) { + for (const toolConstructor of [...AtlasTools, ...MongoDbTools, ...AssistantTools]) { const tool = new toolConstructor({ session: this.session, config: this.userConfig, @@ -302,6 +303,7 @@ export class Server { context: "server", message: `Failed to connect to MongoDB instance using the connection string from the config: ${error instanceof Error ? error.message : String(error)}`, }); + j; } } } diff --git a/src/tools/assistant/assistantTool.ts b/src/tools/assistant/assistantTool.ts new file mode 100644 index 00000000..a368a77a --- /dev/null +++ b/src/tools/assistant/assistantTool.ts @@ -0,0 +1,58 @@ +import { + ToolBase, + type TelemetryToolMetadata, + type ToolArgs, + type ToolCategory, + type ToolConstructorParams, +} from "../tool.js"; +import { createFetch } from "@mongodb-js/devtools-proxy-support"; +import { Server } from "../../server.js"; +import { packageInfo } from "../../common/packageInfo.js"; +import { formatUntrustedData } from "../tool.js"; + +export abstract class AssistantToolBase extends ToolBase { + protected server?: Server; + public category: ToolCategory = "assistant"; + protected baseUrl: URL; + protected requiredHeaders: Headers; + + constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) { + super({ session, config, telemetry, elicitation }); + this.baseUrl = new URL(config.assistantBaseUrl); + this.requiredHeaders = new Headers({ + "x-request-origin": "mongodb-mcp-server", + "user-agent": packageInfo.version ? `mongodb-mcp-server/v${packageInfo.version}` : "mongodb-mcp-server", + }); + } + + public register(server: Server): boolean { + this.server = server; + return super.register(server); + } + + protected resolveTelemetryMetadata(_args: ToolArgs): TelemetryToolMetadata { + // Assistant tool calls are not associated with a specific project or organization + // Therefore, we don't have any values to add to the telemetry metadata + return {}; + } + + protected async callAssistantApi(args: { method: "GET" | "POST"; endpoint: string; body?: unknown }) { + const endpoint = new URL(args.endpoint, this.baseUrl); + const headers = new Headers(this.requiredHeaders); + if (args.method === "POST") { + headers.set("Content-Type", "application/json"); + } + + // Use the same custom fetch implementation as the Atlas API client. + // We need this to support enterprise proxies. + const customFetch = createFetch({ + useEnvironmentVariableProxies: true, + }) as unknown as typeof fetch; + + return await customFetch(endpoint, { + method: args.method, + headers, + body: JSON.stringify(args.body), + }); + } +} diff --git a/src/tools/assistant/listKnowledgeSources.ts b/src/tools/assistant/listKnowledgeSources.ts new file mode 100644 index 00000000..9343c1c5 --- /dev/null +++ b/src/tools/assistant/listKnowledgeSources.ts @@ -0,0 +1,74 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { formatUntrustedData, type OperationType } from "../tool.js"; +import { AssistantToolBase } from "./assistantTool.js"; +import { LogId } from "../../common/logger.js"; +import { stringify as yamlStringify } from "yaml"; + +export type KnowledgeSource = { + /** The name of the data source */ + id: string; + /** The type of the data source */ + type: string; + /** A list of available versions for this data source */ + versions: { + /** The version label of the data source */ + label: string; + /** Whether this version is the current/default version */ + isCurrent: boolean; + }[]; +}; + +export type ListKnowledgeSourcesResponse = { + dataSources: KnowledgeSource[]; +}; + +export const ListKnowledgeSourcesToolName = "list-knowledge-sources"; + +export class ListKnowledgeSourcesTool extends AssistantToolBase { + public name = ListKnowledgeSourcesToolName; + protected description = "List available data sources in the MongoDB Assistant knowledge base"; + protected argsShape = {}; + public operationType: OperationType = "read"; + + protected async execute(): Promise { + const response = await this.callAssistantApi({ + method: "GET", + endpoint: "content/sources", + }); + if (!response.ok) { + const message = `Failed to list knowledge sources: ${response.statusText}`; + this.session.logger.debug({ + id: LogId.assistantListKnowledgeSourcesError, + context: "assistant-list-knowledge-sources", + message, + }); + return { + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + }; + } + const { dataSources } = (await response.json()) as ListKnowledgeSourcesResponse; + + const text = yamlStringify( + dataSources.map((ds) => { + const currentVersion = ds.versions.find(({ isCurrent }) => isCurrent)?.label; + if (currentVersion) { + (ds as KnowledgeSource & { currentVersion: string }).currentVersion = currentVersion; + } + return ds; + }) + ); + + return { + content: formatUntrustedData( + `Found ${dataSources.length} data sources in the MongoDB Assistant knowledge base.`, + text + ), + }; + } +} diff --git a/src/tools/assistant/searchKnowledge.ts b/src/tools/assistant/searchKnowledge.ts new file mode 100644 index 00000000..3dd813c5 --- /dev/null +++ b/src/tools/assistant/searchKnowledge.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { type ToolArgs, type OperationType, formatUntrustedData } from "../tool.js"; +import { AssistantToolBase } from "./assistantTool.js"; +import { LogId } from "../../common/logger.js"; +import { stringify as yamlStringify } from "yaml"; +import { ListKnowledgeSourcesToolName } from "./listKnowledgeSources.js"; + +export const SearchKnowledgeToolArgs = { + query: z + .string() + .describe( + "A natural language query to search for in the MongoDB Assistant knowledge base. This should be a single question or a topic that is relevant to the user's MongoDB use case." + ), + limit: z.number().min(1).max(100).optional().default(5).describe("The maximum number of results to return"), + dataSources: z + .array( + z.object({ + name: z.string().describe("The name of the data source"), + versionLabel: z.string().optional().describe("The version label of the data source"), + }) + ) + .optional() + .describe( + `A list of one or more data sources to limit the search to. You can specify a specific version of a data source by providing the version label. If not provided, the latest version of all data sources will be searched. Available data sources and their versions can be listed by calling the ${ListKnowledgeSourcesToolName} tool.` + ), +}; + +export type SearchKnowledgeResponse = { + /** A list of search results */ + results: { + /** The URL of the search result */ + url: string; + /** The page title of the search result */ + title: string; + /** The text of the page chunk returned from the search */ + text: string; + /** Metadata for the search result */ + metadata: { + /** A list of tags that describe the page */ + tags: string[]; + /** Additional metadata */ + [key: string]: unknown; + }; + }[]; +}; + +export class SearchKnowledgeTool extends AssistantToolBase { + public name = "search-knowledge"; + protected description = "Search for information in the MongoDB Assistant knowledge base"; + protected argsShape = { + ...SearchKnowledgeToolArgs, + }; + public operationType: OperationType = "read"; + + protected async execute(args: ToolArgs): Promise { + const response = await this.callAssistantApi({ + method: "POST", + endpoint: "content/search", + body: args, + }); + if (!response.ok) { + const message = `Failed to search knowledge base: ${response.statusText}`; + this.session.logger.debug({ + id: LogId.assistantSearchKnowledgeError, + context: "assistant-search-knowledge", + message, + }); + return { + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + }; + } + const { results } = (await response.json()) as SearchKnowledgeResponse; + + const text = yamlStringify(results); + + return { + content: formatUntrustedData( + `Found ${results.length} results in the MongoDB Assistant knowledge base.`, + text + ), + }; + } +} diff --git a/src/tools/assistant/tools.ts b/src/tools/assistant/tools.ts new file mode 100644 index 00000000..12f0b9e8 --- /dev/null +++ b/src/tools/assistant/tools.ts @@ -0,0 +1,4 @@ +import { ListKnowledgeSourcesTool } from "./listKnowledgeSources.js"; +import { SearchKnowledgeTool } from "./searchKnowledge.js"; + +export const AssistantTools = [ListKnowledgeSourcesTool, SearchKnowledgeTool]; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index fe36619e..02bca976 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -16,7 +16,7 @@ export type ToolCallbackArgs = Parameters = Parameters>[1]; export type OperationType = "metadata" | "read" | "create" | "delete" | "update" | "connect"; -export type ToolCategory = "mongodb" | "atlas"; +export type ToolCategory = "mongodb" | "atlas" | "assistant"; export type TelemetryToolMetadata = { projectId?: string; orgId?: string; diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index d62354a8..f50f8c5f 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -179,22 +179,26 @@ export function setupIntegrationTest( }; } -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -export function getResponseContent(content: unknown | { content: unknown }): string { +export function getResponseContent(content: unknown): string { return getResponseElements(content) .map((item) => item.text) .join("\n"); } -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -export function getResponseElements(content: unknown | { content: unknown }): { type: string; text: string }[] { +export interface ResponseElement { + type: string; + text: string; + _meta?: unknown; +} + +export function getResponseElements(content: unknown): ResponseElement[] { if (typeof content === "object" && content !== null && "content" in content) { - content = (content as { content: unknown }).content; + content = content.content; } expect(content).toBeInstanceOf(Array); - const response = content as { type: string; text: string }[]; + const response = content as ResponseElement[]; for (const item of response) { expect(item).toHaveProperty("type"); expect(item).toHaveProperty("text"); diff --git a/tests/unit/assistant/assistantHelpers.ts b/tests/unit/assistant/assistantHelpers.ts new file mode 100644 index 00000000..68b36124 --- /dev/null +++ b/tests/unit/assistant/assistantHelpers.ts @@ -0,0 +1,93 @@ +import { + setupIntegrationTest, + IntegrationTest, + defaultTestConfig, + defaultDriverOptions, +} from "../../integration/helpers.js"; +import { describe, SuiteCollector } from "vitest"; +import { vi, beforeAll, afterAll, beforeEach } from "vitest"; + +export type MockIntegrationTestFunction = (integration: IntegrationTest) => void; + +export function describeWithAssistant(name: string, fn: MockIntegrationTestFunction): SuiteCollector { + const testDefinition = (): void => { + const integration = setupIntegrationTest( + () => ({ + ...defaultTestConfig, + assistantBaseUrl: "https://knowledge-mock.mongodb.com/api/v1", // Not a real URL + }), + () => ({ + ...defaultDriverOptions, + }) + ); + + describe(name, () => { + fn(integration); + }); + }; + + // eslint-disable-next-line vitest/valid-describe-callback + return describe("assistant (mocked)", testDefinition); +} + +/** + * Mocks fetch for assistant API calls + */ +interface MockedAssistantAPI { + mockListSources: (sources: unknown[]) => void; + mockSearchResults: (results: unknown[]) => void; + mockAPIError: (status: number, statusText: string) => void; + mockNetworkError: (error: Error) => void; + mockFetch: ReturnType; +} + +export function makeMockAssistantAPI(): MockedAssistantAPI { + const mockFetch = vi.fn(); + + beforeAll(async () => { + const { createFetch } = await import("@mongodb-js/devtools-proxy-support"); + vi.mocked(createFetch).mockReturnValue(mockFetch as never); + }); + + beforeEach(() => { + mockFetch.mockClear(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + const mockListSources: MockedAssistantAPI["mockListSources"] = (sources) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ dataSources: sources }), + }); + }; + + const mockSearchResults: MockedAssistantAPI["mockSearchResults"] = (results) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ results }), + }); + }; + + const mockAPIError: MockedAssistantAPI["mockAPIError"] = (status, statusText) => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status, + statusText, + }); + }; + + const mockNetworkError: MockedAssistantAPI["mockNetworkError"] = (error) => { + mockFetch.mockRejectedValueOnce(error); + }; + + return { + mockListSources, + mockSearchResults, + mockAPIError, + mockNetworkError, + mockFetch, + }; +} diff --git a/tests/unit/assistant/listKnowledgeSources.test.ts b/tests/unit/assistant/listKnowledgeSources.test.ts new file mode 100644 index 00000000..0b656e6d --- /dev/null +++ b/tests/unit/assistant/listKnowledgeSources.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + expectDefined, + validateToolMetadata, + getResponseElements, + getDataFromUntrustedContent, +} from "../../integration/helpers.js"; +import { describeWithAssistant, makeMockAssistantAPI } from "./assistantHelpers.js"; +import { parse as yamlParse } from "yaml"; + +// Mock the devtools-proxy-support module +vi.mock("@mongodb-js/devtools-proxy-support", () => ({ + createFetch: vi.fn(), +})); + +describeWithAssistant("list-knowledge-sources", (integration) => { + const { mockListSources, mockAPIError, mockNetworkError } = makeMockAssistantAPI(); + + validateToolMetadata( + integration, + "list-knowledge-sources", + "List available data sources in the MongoDB Assistant knowledge base", + [] + ); + + describe("happy path", () => { + it("returns list of data sources with metadata", async () => { + const mockSources = [ + { + id: "mongodb-manual", + type: "documentation", + versions: [ + { label: "7.0", isCurrent: true }, + { label: "6.0", isCurrent: false }, + ], + }, + { + id: "node-driver", + type: "driver", + versions: [ + { label: "6.0", isCurrent: true }, + { label: "5.0", isCurrent: false }, + ], + }, + ]; + + mockListSources(mockSources); + + const response = (await integration + .mcpClient() + .callTool({ name: "list-knowledge-sources", arguments: {} })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toBeInstanceOf(Array); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + + // First element is the description + expect(elements[0]?.text).toBe("Found 2 data sources in the MongoDB Assistant knowledge base."); + + // Second element contains the YAML data + expect(elements[1]?.text).toContain(" { + mockListSources([]); + + const response = (await integration + .mcpClient() + .callTool({ name: "list-knowledge-sources", arguments: {} })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toBeInstanceOf(Array); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + expect(elements[0]?.text).toBe("Found 0 data sources in the MongoDB Assistant knowledge base."); + }); + }); + + describe("error handling", () => { + it("handles API error responses", async () => { + mockAPIError(500, "Internal Server Error"); + + const response = (await integration + .mcpClient() + .callTool({ name: "list-knowledge-sources", arguments: {} })) as CallToolResult; + + expect(response.isError).toBe(true); + expectDefined(response.content); + expect(response.content[0]).toHaveProperty("text"); + expect(response.content[0]?.text).toContain("Failed to list knowledge sources: Internal Server Error"); + }); + + it("handles network errors", async () => { + mockNetworkError(new Error("Network connection failed")); + + const response = (await integration + .mcpClient() + .callTool({ name: "list-knowledge-sources", arguments: {} })) as CallToolResult; + + expect(response.isError).toBe(true); + expectDefined(response.content); + expect(response.content[0]).toHaveProperty("text"); + expect(response.content[0]?.text).toContain("Network connection failed"); + }); + }); +}); diff --git a/tests/unit/assistant/searchKnowledge.test.ts b/tests/unit/assistant/searchKnowledge.test.ts new file mode 100644 index 00000000..40ed3798 --- /dev/null +++ b/tests/unit/assistant/searchKnowledge.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from "vitest"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + expectDefined, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, + getDataFromUntrustedContent, +} from "../../integration/helpers.js"; +import { describeWithAssistant, makeMockAssistantAPI } from "./assistantHelpers.js"; +import { parse as yamlParse } from "yaml"; + +// Mock the devtools-proxy-support module +vi.mock("@mongodb-js/devtools-proxy-support", () => ({ + createFetch: vi.fn(), +})); + +describeWithAssistant("search-knowledge", (integration) => { + const { mockSearchResults, mockAPIError, mockNetworkError } = makeMockAssistantAPI(); + + validateToolMetadata( + integration, + "search-knowledge", + "Search for information in the MongoDB Assistant knowledge base", + [ + { + name: "dataSources", + description: + "A list of one or more data sources to search in. You can specify a specific version of a data source by providing the version label. If not provided, the latest version of all data sources will be searched.", + type: "array", + required: false, + }, + { + name: "limit", + description: "The maximum number of results to return", + type: "number", + required: false, + }, + { + name: "query", + description: "A natural language query to search for in the knowledge base", + type: "string", + required: true, + }, + ] + ); + + validateThrowsForInvalidArguments(integration, "search-knowledge", [ + {}, // missing required query + { query: 123 }, // invalid query type + { query: "test", limit: -1 }, // invalid limit + { query: "test", limit: 101 }, // limit too high + { query: "test", dataSources: "invalid" }, // invalid dataSources type + { query: "test", dataSources: [{ name: 123 }] }, // invalid dataSource name type + { query: "test", dataSources: [{}] }, // missing required name field + ]); + + describe("Success Cases", () => { + it("searches with query only", async () => { + const mockResults = [ + { + url: "https://docs.mongodb.com/manual/aggregation/", + title: "Aggregation Pipeline", + text: "The aggregation pipeline is a framework for data aggregation modeled on the concept of data processing pipelines.", + metadata: { + tags: ["aggregation", "pipeline"], + source: "mongodb-manual", + }, + }, + { + url: "https://docs.mongodb.com/manual/reference/operator/aggregation/", + title: "Aggregation Pipeline Operators", + text: "Aggregation pipeline operations have an array of operators available.", + metadata: { + tags: ["aggregation", "operators"], + source: "mongodb-manual", + }, + }, + ]; + + mockSearchResults(mockResults); + + const response = (await integration.mcpClient().callTool({ + name: "search-knowledge", + arguments: { query: "aggregation pipeline" }, + })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toBeInstanceOf(Array); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + + // First element is the description + expect(elements[0]?.text).toBe("Found 2 results in the MongoDB Assistant knowledge base."); + + // Second element contains the YAML data + expect(elements[1]?.text).toContain(" { + const mockResults = [ + { + url: "https://mongodb.github.io/node-mongodb-native/", + title: "Node.js Driver", + text: "The official MongoDB driver for Node.js provides a high-level API on top of mongodb-core.", + metadata: { + tags: ["driver", "nodejs"], + source: "node-driver", + }, + }, + ]; + + mockSearchResults(mockResults); + + const response = (await integration.mcpClient().callTool({ + name: "search-knowledge", + arguments: { + query: "node.js driver", + limit: 1, + dataSources: [{ name: "node-driver", versionLabel: "6.0" }], + }, + })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toBeInstanceOf(Array); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + expect(elements[0]?.text).toBe("Found 1 results in the MongoDB Assistant knowledge base."); + + const yamlData = getDataFromUntrustedContent(elements[1]?.text ?? ""); + const results = yamlParse(yamlData); + + expect(results[0]).toMatchObject({ + url: "https://mongodb.github.io/node-mongodb-native/", + title: "Node.js Driver", + text: "The official MongoDB driver for Node.js provides a high-level API on top of mongodb-core.", + metadata: { + tags: ["driver", "nodejs"], + source: "node-driver", + }, + }); + }); + + it("handles empty search results", async () => { + mockSearchResults([]); + + const response = (await integration + .mcpClient() + .callTool({ name: "search-knowledge", arguments: { query: "nonexistent topic" } })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toBeInstanceOf(Array); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + expect(elements[0]?.text).toBe("Found 0 results in the MongoDB Assistant knowledge base."); + }); + + it("uses default limit when not specified", async () => { + const mockResults = Array(5) + .fill(null) + .map((_, i) => ({ + url: `https://docs.mongodb.com/result${i}`, + title: `Result ${i}`, + text: `Search result number ${i}`, + metadata: { tags: [`tag${i}`] }, + })); + + mockSearchResults(mockResults); + + const response = (await integration + .mcpClient() + .callTool({ name: "search-knowledge", arguments: { query: "test query" } })) as CallToolResult; + + expect(response.isError).toBeFalsy(); + expect(response.content).toHaveLength(2); + + const elements = getResponseElements(response.content); + expect(elements[0]?.text).toBe("Found 5 results in the MongoDB Assistant knowledge base."); + + const yamlData = getDataFromUntrustedContent(elements[1]?.text ?? ""); + const results = yamlParse(yamlData); + expect(results).toHaveLength(5); + }); + }); + + describe("error handling", () => { + it("handles API error responses", async () => { + mockAPIError(404, "Not Found"); + + const response = (await integration + .mcpClient() + .callTool({ name: "search-knowledge", arguments: { query: "test query" } })) as CallToolResult; + + expect(response.isError).toBe(true); + expectDefined(response.content); + expect(response.content[0]).toHaveProperty("text"); + expect(response.content[0]?.text).toContain("Failed to search knowledge base: Not Found"); + }); + + it("handles network errors", async () => { + mockNetworkError(new Error("Connection timeout")); + + const response = (await integration + .mcpClient() + .callTool({ name: "search-knowledge", arguments: { query: "test query" } })) as CallToolResult; + + expect(response.isError).toBe(true); + expectDefined(response.content); + expect(response.content[0]).toHaveProperty("text"); + expect(response.content[0]?.text).toContain("Connection timeout"); + }); + }); +});