Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Add MongoDB Assistant Tools #472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
nlarew wants to merge 7 commits into mongodb-js:main
base: main
Choose a base branch
Loading
from nlarew:assistant-tool
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
6 changes: 6 additions & 0 deletions README.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions src/common/config.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export interface UserConfig extends CliOptions {
apiBaseUrl: string;
apiClientId?: string;
apiClientSecret?: string;
assistantBaseUrl: string;
telemetry: "enabled" | "disabled";
logPath: string;
exportsPath: string;
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/common/logger.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/server.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
} 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";
Expand Down Expand Up @@ -206,7 +207,7 @@
}

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,
Expand Down Expand Up @@ -302,6 +303,7 @@
context: "server",
message: `Failed to connect to MongoDB instance using the connection string from the config: ${error instanceof Error ? error.message : String(error)}`,
});
j;

Check failure on line 306 in src/server.ts

View workflow job for this annotation

GitHub Actions / check-generate

Cannot find name 'j'.

Check failure on line 306 in src/server.ts

View workflow job for this annotation

GitHub Actions / check-style

Cannot find name 'j'.

Check failure on line 306 in src/server.ts

View workflow job for this annotation

GitHub Actions / Check dependencies

Cannot find name 'j'.
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/tools/assistant/assistantTool.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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",
};
Comment on lines +23 to +28
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?

Copy link
Author

Choose a reason for hiding this comment

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

It looks like the apiClient is specifically for talking to Atlas/MMS APIs? The knowledge APIs are a distinct server so I don't think it will work for this.

do you need specific headers

Yeah we must include, at minimum, a user-agent + origin or x-request-origin header

is this page not authenticated

correct - the knowledge API is not authenticated

}

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

protected resolveTelemetryMetadata(_args: ToolArgs<typeof this.argsShape>): 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 {};
Copy link
Author

@nlarew nlarew Aug 22, 2025
edited
Loading

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.

Copy link
Author

Choose a reason for hiding this comment

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

The TelemetryToolMetadata type seems to track a specific Atlas org/project but the assistant is not associated with a given Atlas instance. Planning to leave this empty unless someone has a good idea of what to put here.

nirinchev reacted with thumbs up emoji
}

protected handleError(
error: unknown,
args: ToolArgs<typeof this.argsShape>
): Promise<CallToolResult> | CallToolResult {
return super.handleError(error, args);
}
Comment on lines +42 to +47
Copy link
Collaborator

@nirinchev nirinchev Sep 26, 2025

Choose a reason for hiding this comment

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

You don't need this if you're not adding any special error handling.

}
66 changes: 66 additions & 0 deletions src/tools/assistant/listKnowledgeSources.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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<CallToolResult> {
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());
Comment on lines +31 to +53
Copy link
Collaborator

@nirinchev nirinchev Sep 26, 2025

Choose a reason for hiding this comment

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

Can we abstract this and move it to the base tool? Essentially, expose a function like:

protected function fetchKnowledgeBase<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T>

that calls fetch, parses the response, logs errors, etc. Additionally, we tend to prefer working with pure typescript types rather than zod schemas for API calls. Those are easier to reason about and more succinct to type.


return {
content: dataSources.map(({ id, type, versions }) => ({
type: "text",
text: id,
_meta: {
type,
versions,
},
})),
};
}
}
83 changes: 83 additions & 0 deletions src/tools/assistant/searchKnowledge.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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<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),
});
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,
},
})),
};
}
}
4 changes: 4 additions & 0 deletions src/tools/assistant/tools.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ListKnowledgeSourcesTool } from "./listKnowledgeSources.js";
import { SearchKnowledgeTool } from "./searchKnowledge.js";

export const AssistantTools = [ListKnowledgeSourcesTool, SearchKnowledgeTool];
2 changes: 1 addition & 1 deletion src/tools/tool.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ToolCallbackArgs<Args extends ZodRawShape> = Parameters<ToolCallback
export type ToolExecutionContext<Args extends ZodRawShape = ZodRawShape> = Parameters<ToolCallback<Args>>[1];

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

nlarew reacted with thumbs up emoji
export type TelemetryToolMetadata = {
projectId?: string;
orgId?: string;
Expand Down
16 changes: 10 additions & 6 deletions tests/integration/helpers.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
78 changes: 78 additions & 0 deletions tests/integration/tools/assistant/assistantHelpers.ts
View file Open in desktop
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(() => ({
...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

AltStyle によって変換されたページ (->オリジナル) /