-
Notifications
You must be signed in to change notification settings - Fork 124
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
Changes from all commits
2338504
881c4ee
3bdd3df
59b3c63
34d50d8
bb5e585
d3b4d6b
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,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
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. can we try to use the Also, is this page not authenticated? 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. It looks like the
Yeah we must include, at minimum, a
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 {}; | ||
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. Not sure what if anything I should have here - would appreciate advice from the DevTools team on this. 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. The |
||
} | ||
|
||
protected handleError( | ||
error: unknown, | ||
args: ToolArgs<typeof this.argsShape> | ||
): Promise<CallToolResult> | CallToolResult { | ||
return super.handleError(error, args); | ||
} | ||
Comment on lines
+42
to
+47
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. You don't need this if you're not adding any special error handling. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { z } from "zod"; | ||
nlarew marked this conversation as resolved.
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 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
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. 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, | ||
}, | ||
})), | ||
}; | ||
} | ||
} |
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"), | ||
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 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), | ||
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
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, | ||
}, | ||
})), | ||
}; | ||
} | ||
} |
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]; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
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. 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; | ||
|
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, | ||
}; | ||
} |