-
Notifications
You must be signed in to change notification settings - Fork 128
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
base: main
Are you sure you want to change the base?
Changes from 11 commits
2338504
881c4ee
3bdd3df
59b3c63
34d50d8
bb5e585
d3b4d6b
2736bca
f8365ac
3cb9883
4671347
4d887c9
caa7792
1415205
f4bec2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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", | ||||||||||||||||
}); | ||||||||||||||||
} | ||||||||||||||||
|
||||||||||||||||
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 {}; | ||||||||||||||||
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||
} | ||||||||||||||||
|
||||||||||||||||
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, { | ||||||||||||||||
|
// 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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,64 @@ | ||||||||||||||||||||||||
import { z } from "zod"; | ||||||||||||||||||||||||
nlarew marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: mongodb-mcp-server/src/tools/mongodb/read/find.ts Lines 93 to 103 in 04a5ddc
And also we should consider the information in the knowledge list untrusted. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to wrap API error text in 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,
};
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I think |
||||||||||||||||||||||||
} | ||||||||||||||||||||||||
} |
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"), | ||
|
||
limit: z.number().min(1).max(100).optional().default(5).describe("The maximum number of results to return"), | ||
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 }) => ({ | ||
nlarew marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
type: "text", | ||
text, | ||
_meta: { | ||
...metadata, | ||
}, | ||
})), | ||
}; | ||
} | ||
} |
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]; |
There was a problem hiding this comment.
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 everywhereThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.mongodb-mcp-server
apiClient
usesUser-Agent: AtlasMCP
which again seems to refer to Atlas specifically