diff --git a/README.md b/README.md index e5915ed2..384d0d5f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,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) @@ -321,6 +322,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 +- `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/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..359a6062 --- /dev/null +++ b/src/tools/assistant/assistantTool.ts @@ -0,0 +1,48 @@ +import { + ToolBase, + type TelemetryToolMetadata, + type ToolArgs, + type ToolCategory, + type ToolConstructorParams, +} from "../tool.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Server } from "../../server.js"; +import { Session } from "../../common/session.js"; +import { UserConfig } from "../../common/config.js"; +import { Telemetry } from "../../telemetry/telemetry.js"; +import { packageInfo } from "../../common/packageInfo.js"; + +export abstract class AssistantToolBase extends ToolBase { + protected server?: Server; + public category: ToolCategory = "assistant"; + protected baseUrl: URL; + protected requiredHeaders: Record; + + constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) { + super({ session, config, telemetry, elicitation }); + this.baseUrl = new URL(config.assistantBaseUrl); + const serverVersion = packageInfo.version; + this.requiredHeaders = { + "x-request-origin": "mongodb-mcp-server", + "user-agent": serverVersion ? `mongodb-mcp-server/v${serverVersion}` : "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 handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + return super.handleError(error, args); + } +} diff --git a/src/tools/assistant/listKnowledgeSources.ts b/src/tools/assistant/listKnowledgeSources.ts new file mode 100644 index 00000000..13c02b4b --- /dev/null +++ b/src/tools/assistant/listKnowledgeSources.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { OperationType } from "../tool.js"; +import { AssistantToolBase } from "./assistantTool.js"; +import { LogId } from "../../common/logger.js"; + +export const dataSourceMetadataSchema = z.object({ + id: z.string().describe("The name of the data source"), + type: z.string().optional().describe("The type of the data source"), + versions: z + .array( + z.object({ + label: z.string().describe("The version label of the data source"), + isCurrent: z.boolean().describe("Whether this version is current active version"), + }) + ) + .describe("A list of available versions for this data source"), +}); + +export const listDataSourcesResponseSchema = z.object({ + dataSources: z.array(dataSourceMetadataSchema).describe("A list of data sources"), +}); + +export class ListKnowledgeSourcesTool extends AssistantToolBase { + public name = "list-knowledge-sources"; + protected description = "List available data sources in the MongoDB Assistant knowledge base"; + protected argsShape = {}; + public operationType: OperationType = "read"; + + protected async execute(): Promise { + const searchEndpoint = new URL("content/sources", this.baseUrl); + const response = await fetch(searchEndpoint, { + method: "GET", + headers: this.requiredHeaders, + }); + 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 } = listDataSourcesResponseSchema.parse(await response.json()); + + return { + content: dataSources.map(({ id, type, versions }) => ({ + type: "text", + text: id, + _meta: { + type, + versions, + }, + })), + }; + } +} diff --git a/src/tools/assistant/searchKnowledge.ts b/src/tools/assistant/searchKnowledge.ts new file mode 100644 index 00000000..93abcb68 --- /dev/null +++ b/src/tools/assistant/searchKnowledge.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { ToolArgs, OperationType } from "../tool.js"; +import { AssistantToolBase } from "./assistantTool.js"; +import { LogId } from "../../common/logger.js"; + +export const SearchKnowledgeToolArgs = { + query: z.string().describe("A natural language query to search for in the knowledge base"), + 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 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." + ), +}; + +export const knowledgeChunkSchema = z + .object({ + url: z.string().describe("The URL of the search result"), + title: z.string().describe("Title of the search result"), + text: z.string().describe("Chunk text"), + metadata: z + .object({ + tags: z.array(z.string()).describe("The tags of the source"), + }) + .passthrough(), + }) + .passthrough(); + +export const searchResponseSchema = z.object({ + results: z.array(knowledgeChunkSchema).describe("A list of search results"), +}); + +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 searchEndpoint = new URL("content/search", this.baseUrl); + const response = await fetch(searchEndpoint, { + method: "POST", + headers: new Headers({ ...this.requiredHeaders, "Content-Type": "application/json" }), + body: JSON.stringify(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 } = searchResponseSchema.parse(await response.json()); + return { + content: results.map(({ text, metadata }) => ({ + type: "text", + text, + _meta: { + ...metadata, + }, + })), + }; + } +} 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/integration/tools/assistant/assistantHelpers.ts b/tests/integration/tools/assistant/assistantHelpers.ts new file mode 100644 index 00000000..816d6d2c --- /dev/null +++ b/tests/integration/tools/assistant/assistantHelpers.ts @@ -0,0 +1,78 @@ +import { setupIntegrationTest, IntegrationTest, defaultTestConfig } from "../../helpers.js"; +import { describe, SuiteCollector } from "vitest"; +import { vi, beforeAll, afterAll } from "vitest"; + +export type IntegrationTestFunction = (integration: IntegrationTest) => void; + +export function describeWithAssistant(name: string, fn: IntegrationTestFunction): SuiteCollector { + const testDefinition = (): void => { + const integration = setupIntegrationTest(() => ({ + ...defaultTestConfig, + assistantBaseUrl: "https://knowledge.test.mongodb.com/api/", // Use test URL + })); + + describe(name, () => { + fn(integration); + }); + }; + + // eslint-disable-next-line vitest/valid-describe-callback + return describe("assistant", 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(() => { + global.fetch = mockFetch; + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + const mockListSources: MockedAssistantAPI["mockListSources"] = (sources) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ dataSources: sources }), + }); + }; + + const mockSearchResults: MockedAssistantAPI["mockSearchResults"] = (results) => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ 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/integration/tools/assistant/listKnowledgeSources.test.ts b/tests/integration/tools/assistant/listKnowledgeSources.test.ts new file mode 100644 index 00000000..f1761c3a --- /dev/null +++ b/tests/integration/tools/assistant/listKnowledgeSources.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { expectDefined, validateToolMetadata, getResponseElements } from "../../helpers.js"; +import { describeWithAssistant, makeMockAssistantAPI } from "./assistantHelpers.js"; + +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); + + // Check first data source + expect(elements[0]?.text).toBe("mongodb-manual"); + expect(elements[0]?._meta).toEqual({ + type: "documentation", + versions: [ + { label: "7.0", isCurrent: true }, + { label: "6.0", isCurrent: false }, + ], + }); + + // Check second data source + expect(elements[1]?.text).toBe("node-driver"); + expect(elements[1]?._meta).toEqual({ + type: "driver", + versions: [ + { label: "6.0", isCurrent: true }, + { label: "5.0", isCurrent: false }, + ], + }); + }); + + it("handles empty data sources list", async () => { + 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(0); + }); + }); + + 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"); + }); + + it("handles malformed API response", async () => { + // Mock a response that doesn't match the expected schema + mockListSources(["invalid-response"]); + + 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"); + // Should contain some indication of a parsing/validation error + expect(response.content[0]?.text).toMatch(/error/i); + }); + }); +}); diff --git a/tests/integration/tools/assistant/searchKnowledge.test.ts b/tests/integration/tools/assistant/searchKnowledge.test.ts new file mode 100644 index 00000000..43552e68 --- /dev/null +++ b/tests/integration/tools/assistant/searchKnowledge.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + expectDefined, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, +} from "../../helpers.js"; +import { describeWithAssistant, makeMockAssistantAPI } from "./assistantHelpers.js"; + +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); + + // Check first result + expect(elements[0]?.text).toBe( + "The aggregation pipeline is a framework for data aggregation modeled on the concept of data processing pipelines." + ); + expect(elements[0]?._meta).toEqual({ + tags: ["aggregation", "pipeline"], + source: "mongodb-manual", + }); + + // Check second result + expect(elements[1]?.text).toBe("Aggregation pipeline operations have an array of operators available."); + expect(elements[1]?._meta).toEqual({ + tags: ["aggregation", "operators"], + source: "mongodb-manual", + }); + }); + + it("searches with query, limit, and dataSources", async () => { + 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(1); + + const elements = getResponseElements(response.content); + expect(elements[0]?.text).toBe( + "The official MongoDB driver for Node.js provides a high-level API on top of mongodb-core." + ); + expect(elements[0]?._meta).toEqual({ + 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(0); + }); + + 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(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"); + }); + + it("handles malformed API response", async () => { + // Mock a response that doesn't match the expected schema + mockSearchResults(["invalid-response"]); + + 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"); + // Should contain some indication of a parsing/validation error + expect(response.content[0]?.text).toMatch(/error/i); + }); + }); +});

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /