Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions src/common/config.ts
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
}
Expand Down
51 changes: 51 additions & 0 deletions src/tools/assistant/assistantTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 { packageInfo } from "../../common/packageInfo.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);
const serverVersion = packageInfo.version;
this.requiredHeaders = new Headers({
"x-request-origin": "mongodb-mcp-server",
"user-agent": serverVersion ? `mongodb-mcp-server/v${serverVersion}` : "mongodb-mcp-server",
});
Comment on lines 22 to 25
Copy link
Collaborator

Choose a reason for hiding this comment

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

[q] why does this need to be initialized here at the tool level? can we also move this logic into the api layer?

also, we should probably use the userAgent from the apiClient and make sure we use the same name everywhere

Copy link
Author

Choose a reason for hiding this comment

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

why does this need to be initialized here at the tool level? can we also move this logic into the api layer?

The "API layer" as it exists seems to be tailored to Atlas - this is a totally separate server/API. If we prefer I could create an equivalent assistant/apiClient.ts file but personally that seems unnecessary at this point.

we should probably use the userAgent from the apiClient

  1. Our server firewall allowlists specific user agents and right now that's configured to be mongodb-mcp-server
  2. nit: The apiClient uses User-Agent: AtlasMCP which again seems to refer to Atlas specifically

}

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 {};
}

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");
}
return await fetch(endpoint, {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't use Node.js fetch, but customFetch configured like in the Atlas Client:

// createFetch assumes that the first parameter of fetch is always a string
// with the URL. However, fetch can also receive a Request object. While
// the typechecking complains, createFetch does passthrough the parameters
// so it works fine.
private static customFetch: typeof fetch = createFetch({
useEnvironmentVariableProxies: true,
}) as unknown as typeof fetch;

The reason is that the customFetch supports enterprise proxies, which are relevant for customers with specific security requirements.

Copy link
Collaborator

Choose a reason for hiding this comment

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

should we move this into apiClient? we also have telemetry events logic there, it would be good to keep all the api related logic there for now

Copy link
Author

@nlarew nlarew Oct 3, 2025

Choose a reason for hiding this comment

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

Ok no problem - I will create a new customFetch instance in the assistant tool base because we don't need all of the extra baggage from the Atlas ApiClient class when working with this non-Atlas API.

method: args.method,
headers,
body: JSON.stringify(args.body),
});
}
}
64 changes: 64 additions & 0 deletions src/tools/assistant/listKnowledgeSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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 type ListKnowledgeSourcesResponse = {
dataSources: {
/** 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 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 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;

return {
content: dataSources.map(({ id, type, versions }) => ({
type: "text",
text: id,
_meta: {
type,
versions,
},
})),
};
Comment on lines 67 to 72
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should convert this into a string, similar to what we do in:

return {
content: formatUntrustedData(
this.generateMessage({
collection,
queryResultsCount,
documents: cursorResults.documents,
appliedLimits: [limitOnFindCursor.cappedBy, cursorResults.cappedBy].filter((limit) => !!limit),
}),
cursorResults.documents.length > 0 ? EJSON.stringify(cursorResults.documents) : undefined
),
};

And also we should consider the information in the knowledge list untrusted.

Copy link
Author

@nlarew nlarew Oct 3, 2025

Choose a reason for hiding this comment

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

Looks like formatUntrustedData maps to a single CallToolResult["content"] object so I will need to convert the array of results I get from each API call into a single formatted string.

Copy link
Author

@nlarew nlarew Oct 3, 2025

Choose a reason for hiding this comment

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

Do we want to wrap API error text in formatUntrustedData as well? Not sure if that's already mitigated by the isError: true or not.

i.e. instead of

if (!response.ok) {
    return {
        content: [
            {
                type: "text",
                text: `Failed to list knowledge sources: ${response.statusText}`,
            },
        ],
        isError: true,
    };
}

we would have

if (!response.ok) {
    return {
        content: formatUntrustedData("Failed to list knowledge sources", response.statusText),
        isError: true,
    };
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't believe the LLM will employ any injection protection measures regardless of whether the response is marked as error-ed or not. That being said, do we expect that statusText would contain any instructions - this should be just the textual representation of the status code, shouldn't it? I wouldn't expect there to be a way to inject anything malicious in it.

Copy link
Author

Choose a reason for hiding this comment

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

Yeah I think statusCode should only ever be something reasonable like Ok, Not Found, etc.

}
}
84 changes: 84 additions & 0 deletions src/tools/assistant/searchKnowledge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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"),
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: can we add a bit more detail here? this is what the LLM will use to understand that you want it to make a generic natural question that the user haave.

Copy link
Author

Choose a reason for hiding this comment

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

Updated!

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 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<typeof this.argsShape>): Promise<CallToolResult> {
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;
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 "./listKnowledgeSources.js";
import { SearchKnowledgeTool } from "./searchKnowledge.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 @@ -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";
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 @@ -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
Loading
Loading