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: 2 additions & 0 deletions src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface UserConfig extends CliOptions {
apiBaseUrl: string;
apiClientId?: string;
apiClientSecret?: string;
assistantBaseUrl: string;
telemetry: "enabled" | "disabled";
logPath: string;
exportsPath: string;
Expand All @@ -123,6 +124,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: 300000, // 5 minutes
Expand Down
3 changes: 2 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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";

export interface ServerOptions {
Expand Down Expand Up @@ -175,7 +176,7 @@ export class Server {
}

private registerTools(): void {
for (const toolConstructor of [...AtlasTools, ...MongoDbTools]) {
for (const toolConstructor of [...AtlasTools, ...MongoDbTools, ...AssistantTools]) {
const tool = new toolConstructor(this.session, this.userConfig, this.telemetry);
if (tool.register(this)) {
this.tools.push(tool);
Expand Down
47 changes: 47 additions & 0 deletions src/tools/assistant/assistantTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { TelemetryToolMetadata, ToolArgs, ToolBase, ToolCategory } 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<string, string>;

constructor(
protected readonly session: Session,
protected readonly config: UserConfig,
protected readonly telemetry: Telemetry
) {
super(session, config, telemetry);
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",
};
Comment on lines +21 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we try to use the apiClient to make this call? do you need specific headers or can you leverage that to make requests? Check getIpInfo in that file for a customized call

Also, is this page not authenticated?

}

public register(server: Server): boolean {
this.server = server;
return super.register(server);
}

protected resolveTelemetryMetadata(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
args: ToolArgs<typeof this.argsShape>
): TelemetryToolMetadata {
return {};
Copy link
Author

@nlarew nlarew Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what if anything I should have here - would appreciate advice from the DevTools team on this.

}

protected handleError(
error: unknown,
args: ToolArgs<typeof this.argsShape>
): Promise<CallToolResult> | CallToolResult {
return super.handleError(error, args);
}
}
51 changes: 51 additions & 0 deletions src/tools/assistant/list_knowledge_sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { z } from "zod";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we rename the files to camelCase for consistency?

import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { OperationType } from "../tool.js";
import { AssistantToolBase } from "./assistantTool.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<CallToolResult> {
const searchEndpoint = new URL("content/sources", this.baseUrl);
const response = await fetch(searchEndpoint, {
method: "GET",
headers: this.requiredHeaders,
});
if (!response.ok) {
throw new Error(`Failed to list knowledge sources: ${response.statusText}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we instead return content instead of throwing an exception? we've been changing this across the code. it's better to have the tool return a response but with the proper messaging

}
const { dataSources } = listDataSourcesResponseSchema.parse(await response.json());

return {
content: dataSources.map(({ id, type, versions }) => ({
type: "text",
text: id,
_meta: {
type,
versions,
},
})),
};
}
}
68 changes: 68 additions & 0 deletions src/tools/assistant/search_knowledge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { ToolArgs, OperationType } from "../tool.js";
import { AssistantToolBase } from "./assistantTool.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"),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
limit: z.number().min(1).max(100).optional().default(5).describe("The maximum number of results to return"),
limit: z.number().min(1).max(100).optional().default(10).describe("The maximum number of results to return"),

i've seen 10 chunks in a bunch of other implementations. we dont do this in our chatbot, but that's largely for latency/price

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured this should match the default of 5 we have on our server, no? Open to changing that though.

https://github.com/mongodb/chatbot/blob/003cec9b918c8e2ff3c87696021e1d6488c95b98/packages/mongodb-chatbot-server/src/routes/content/searchContent.ts#L27

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";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: all out tool names use - instead of _ could we update them for consistency?

protected description = "Search for information in the MongoDB Assistant knowledge base";
protected argsShape = {
...SearchKnowledgeToolArgs,
};
public operationType: OperationType = "read";

protected async execute(args: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
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),
Comment on lines +48 to +52
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as before, let's keep api calls under the client for proper abtraction and consistency

});
if (!response.ok) {
throw new Error(`Failed to search knowledge base: ${response.statusText}`);
}
const { results } = searchResponseSchema.parse(await response.json());
return {
content: results.map(({ text, metadata }) => ({
type: "text",
text,
_meta: {
...metadata,
},
})),
};
}
}
4 changes: 4 additions & 0 deletions src/tools/assistant/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ListKnowledgeSourcesTool } from "./list_knowledge_sources.js";
import { SearchKnowledgeTool } from "./search_knowledge.js";

export const AssistantTools = [ListKnowledgeSourcesTool, SearchKnowledgeTool];
2 changes: 1 addition & 1 deletion src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { Server } from "../server.js";
export type ToolArgs<Args extends ZodRawShape> = z.objectOutputType<Args, ZodNever>;

export type OperationType = "metadata" | "read" | "create" | "delete" | "update" | "connect";
export type ToolCategory = "mongodb" | "atlas";
export type ToolCategory = "mongodb" | "atlas" | "assistant";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we're here, can we also update the readme.md? there we call out all the tool categories

export type TelemetryToolMetadata = {
projectId?: string;
orgId?: string;
Expand Down
16 changes: 10 additions & 6 deletions tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,22 +146,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");
Expand Down
78 changes: 78 additions & 0 deletions tests/integration/tools/assistant/assistantHelpers.ts
Original file line number Diff line number Diff line change
@@ -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<object> {
const testDefinition = (): void => {
const integration = setupIntegrationTest(() => ({

Check failure on line 9 in tests/integration/tools/assistant/assistantHelpers.ts

View workflow job for this annotation

GitHub Actions / check-style

Expected 2 arguments, but got 1.
...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<typeof vi.fn>;
}

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,
};
}
Loading
Loading