-
Notifications
You must be signed in to change notification settings - Fork 116
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 all commits
2338504
881c4ee
3bdd3df
59b3c63
34d50d8
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,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", | ||
}; | ||
} | ||
|
||
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 {}; | ||
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. |
||
} | ||
|
||
protected handleError( | ||
error: unknown, | ||
args: ToolArgs<typeof this.argsShape> | ||
): Promise<CallToolResult> | CallToolResult { | ||
return super.handleError(error, args); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { z } from "zod"; | ||
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. 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}`); | ||
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 instead return |
||
} | ||
const { dataSources } = listDataSourcesResponseSchema.parse(await response.json()); | ||
|
||
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,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"), | ||||||
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
nlarew marked this conversation as resolved.
Show resolved
Hide resolved
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.
Suggested change
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 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 figured this should match the default of |
||||||
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"; | ||||||
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. nit: all out tool names use |
||||||
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
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. 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, | ||||||
}, | ||||||
})), | ||||||
}; | ||||||
} | ||||||
} |
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]; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
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, | ||
}; | ||
} |
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.
can we try to use the
apiClient
to make this call? do you need specific headers or can you leverage that to make requests? CheckgetIpInfo
in that file for a customized callAlso, is this page not authenticated?