diff --git a/src/tools/mongodb/read/aggregate.ts b/src/tools/mongodb/read/aggregate.ts index a3da96d1..c5824785 100644 --- a/src/tools/mongodb/read/aggregate.ts +++ b/src/tools/mongodb/read/aggregate.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; +import { EJSON } from "bson"; export const AggregateArgs = { pipeline: z.array(z.object({}).passthrough()).describe("An array of aggregation stages to execute"), - limit: z.number().optional().default(10).describe("The maximum number of documents to return"), }; export class AggregateTool extends MongoDBToolBase { @@ -27,12 +27,12 @@ export class AggregateTool extends MongoDBToolBase { const content: Array<{ text: string; type: "text" }> = [ { - text: `Found ${documents.length} documents in the collection \`${collection}\`:`, + text: `Found ${documents.length} documents in the collection "${collection}":`, type: "text", }, ...documents.map((doc) => { return { - text: JSON.stringify(doc), + text: EJSON.stringify(doc), type: "text", } as { text: string; type: "text" }; }), diff --git a/src/tools/mongodb/read/collectionIndexes.ts b/src/tools/mongodb/read/collectionIndexes.ts index 8fdb0c57..cc0a141b 100644 --- a/src/tools/mongodb/read/collectionIndexes.ts +++ b/src/tools/mongodb/read/collectionIndexes.ts @@ -13,12 +13,36 @@ export class CollectionIndexesTool extends MongoDBToolBase { const indexes = await provider.getIndexes(database, collection); return { - content: indexes.map((indexDefinition) => { - return { - text: `Field: ${indexDefinition.name}: ${JSON.stringify(indexDefinition.key)}`, + content: [ + { + text: `Found ${indexes.length} indexes in the collection "${collection}":`, type: "text", - }; - }), + }, + ...(indexes.map((indexDefinition) => { + return { + text: `Name "${indexDefinition.name}", definition: ${JSON.stringify(indexDefinition.key)}`, + type: "text", + }; + }) as { text: string; type: "text" }[]), + ], }; } + + protected handleError( + error: unknown, + args: ToolArgs<typeof this.argsShape> + ): Promise<CallToolResult> | CallToolResult { + if (error instanceof Error && "codeName" in error && error.codeName === "NamespaceNotFound") { + return { + content: [ + { + text: `The indexes for "${args.database}.${args.collection}" cannot be determined because the collection does not exist.`, + type: "text", + }, + ], + }; + } + + return super.handleError(error, args); + } } diff --git a/src/tools/mongodb/read/find.ts b/src/tools/mongodb/read/find.ts index c87f21fe..e0f806b0 100644 --- a/src/tools/mongodb/read/find.ts +++ b/src/tools/mongodb/read/find.ts @@ -3,6 +3,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import { ToolArgs, OperationType } from "../../tool.js"; import { SortDirection } from "mongodb"; +import { EJSON } from "bson"; export const FindArgs = { filter: z @@ -44,12 +45,12 @@ export class FindTool extends MongoDBToolBase { const content: Array<{ text: string; type: "text" }> = [ { - text: `Found ${documents.length} documents in the collection \`${collection}\`:`, + text: `Found ${documents.length} documents in the collection "${collection}":`, type: "text", }, ...documents.map((doc) => { return { - text: JSON.stringify(doc), + text: EJSON.stringify(doc), type: "text", } as { text: string; type: "text" }; }), diff --git a/tests/integration/tools/mongodb/read/aggregate.test.ts b/tests/integration/tools/mongodb/read/aggregate.test.ts new file mode 100644 index 00000000..148117e1 --- /dev/null +++ b/tests/integration/tools/mongodb/read/aggregate.test.ts @@ -0,0 +1,98 @@ +import { + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, +} from "../../../helpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +describeWithMongoDB("aggregate tool", (integration) => { + validateToolMetadata(integration, "aggregate", "Run an aggregation against a MongoDB collection", [ + ...databaseCollectionParameters, + { + name: "pipeline", + description: "An array of aggregation stages to execute", + type: "array", + required: true, + }, + ]); + + validateThrowsForInvalidArguments(integration, "aggregate", [ + {}, + { database: "test", collection: "foo" }, + { database: test, pipeline: [] }, + { database: "test", collection: "foo", pipeline: {} }, + { database: "test", collection: "foo", pipeline: [], extra: "extra" }, + { database: "test", collection: [], pipeline: [] }, + { database: 123, collection: "foo", pipeline: [] }, + ]); + + it("can run aggragation on non-existent database", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "aggregate", + arguments: { database: "non-existent", collection: "people", pipeline: [{ $match: { name: "Peter" } }] }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(1); + expect(elements[0].text).toEqual('Found 0 documents in the collection "people":'); + }); + + it("can run aggragation on an empty collection", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "aggregate", + arguments: { + database: integration.randomDbName(), + collection: "people", + pipeline: [{ $match: { name: "Peter" } }], + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(1); + expect(elements[0].text).toEqual('Found 0 documents in the collection "people":'); + }); + + it("can run aggragation on an existing collection", async () => { + const mongoClient = integration.mongoClient(); + await mongoClient + .db(integration.randomDbName()) + .collection("people") + .insertMany([ + { name: "Peter", age: 5 }, + { name: "Laura", age: 10 }, + { name: "Søren", age: 15 }, + ]); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "aggregate", + arguments: { + database: integration.randomDbName(), + collection: "people", + pipeline: [{ $match: { age: { $gt: 8 } } }, { $sort: { name: -1 } }], + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(3); + expect(elements[0].text).toEqual('Found 2 documents in the collection "people":'); + expect(JSON.parse(elements[1].text)).toEqual({ _id: expect.any(Object), name: "Søren", age: 15 }); + expect(JSON.parse(elements[2].text)).toEqual({ _id: expect.any(Object), name: "Laura", age: 10 }); + }); + + validateAutoConnectBehavior(integration, "aggregate", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + pipeline: [{ $match: { name: "Liva" } }], + }, + expectedResponse: 'Found 0 documents in the collection "coll1"', + }; + }); +}); diff --git a/tests/integration/tools/mongodb/read/collectionIndexes.test.ts b/tests/integration/tools/mongodb/read/collectionIndexes.test.ts new file mode 100644 index 00000000..2e919080 --- /dev/null +++ b/tests/integration/tools/mongodb/read/collectionIndexes.test.ts @@ -0,0 +1,98 @@ +import { IndexDirection } from "mongodb"; +import { + databaseCollectionParameters, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, + databaseCollectionInvalidArgs, +} from "../../../helpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +describeWithMongoDB("collectionIndexes tool", (integration) => { + validateToolMetadata( + integration, + "collection-indexes", + "Describe the indexes for a collection", + databaseCollectionParameters + ); + + validateThrowsForInvalidArguments(integration, "collection-indexes", databaseCollectionInvalidArgs); + + it("can inspect indexes on non-existent database", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { database: "non-existent", collection: "people" }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(1); + expect(elements[0].text).toEqual( + 'The indexes for "non-existent.people" cannot be determined because the collection does not exist.' + ); + }); + + it("returns the _id index for a new collection", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { + database: integration.randomDbName(), + collection: "people", + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + expect(elements[0].text).toEqual('Found 1 indexes in the collection "people":'); + expect(elements[1].text).toEqual('Name "_id_", definition: {"_id":1}'); + }); + + it("returns all indexes for a collection", async () => { + await integration.mongoClient().db(integration.randomDbName()).createCollection("people"); + + const indexTypes: IndexDirection[] = [-1, 1, "2d", "2dsphere", "text", "hashed"]; + for (const indexType of indexTypes) { + await integration + .mongoClient() + .db(integration.randomDbName()) + .collection("people") + .createIndex({ [`prop_${indexType}`]: indexType }); + } + + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "collection-indexes", + arguments: { + database: integration.randomDbName(), + collection: "people", + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(indexTypes.length + 2); + expect(elements[0].text).toEqual(`Found ${indexTypes.length + 1} indexes in the collection "people":`); + expect(elements[1].text).toEqual('Name "_id_", definition: {"_id":1}'); + + for (const indexType of indexTypes) { + const index = elements.find((element) => element.text.includes(`prop_${indexType}`)); + expect(index).toBeDefined(); + + let expectedDefinition = JSON.stringify({ [`prop_${indexType}`]: indexType }); + if (indexType === "text") { + expectedDefinition = '{"_fts":"text"'; + } + + expect(index!.text).toContain(`definition: ${expectedDefinition}`); + } + }); + + validateAutoConnectBehavior(integration, "collection-indexes", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1" }, + expectedResponse: `The indexes for "${integration.randomDbName()}.coll1" cannot be determined because the collection does not exist.`, + }; + }); +}); diff --git a/tests/integration/tools/mongodb/read/find.test.ts b/tests/integration/tools/mongodb/read/find.test.ts new file mode 100644 index 00000000..f2a3cfc3 --- /dev/null +++ b/tests/integration/tools/mongodb/read/find.test.ts @@ -0,0 +1,182 @@ +import { + getResponseContent, + databaseCollectionParameters, + setupIntegrationTest, + validateToolMetadata, + validateThrowsForInvalidArguments, + getResponseElements, +} from "../../../helpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; + +describeWithMongoDB("find tool", (integration) => { + validateToolMetadata(integration, "find", "Run a find query against a MongoDB collection", [ + ...databaseCollectionParameters, + + { + name: "filter", + description: "The query filter, matching the syntax of the query argument of db.collection.find()", + type: "object", + required: false, + }, + { + name: "projection", + description: "The projection, matching the syntax of the projection argument of db.collection.find()", + type: "object", + required: false, + }, + { + name: "limit", + description: "The maximum number of documents to return", + type: "number", + required: false, + }, + { + name: "sort", + description: + "A document, describing the sort order, matching the syntax of the sort argument of cursor.sort()", + type: "object", + required: false, + }, + ]); + + validateThrowsForInvalidArguments(integration, "find", [ + {}, + { database: 123, collection: "bar" }, + { database: "test", collection: "bar", extra: "extra" }, + { database: "test", collection: [] }, + { database: "test", collection: "bar", filter: "{ $gt: { foo: 5 } }" }, + { database: "test", collection: "bar", projection: "name" }, + { database: "test", collection: "bar", limit: "10" }, + { database: "test", collection: "bar", sort: [], limit: 10 }, + ]); + + it("returns 0 when database doesn't exist", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "find", + arguments: { database: "non-existent", collection: "foos" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual('Found 0 documents in the collection "foos":'); + }); + + it("returns 0 when collection doesn't exist", async () => { + await integration.connectMcpClient(); + const mongoClient = integration.mongoClient(); + await mongoClient.db(integration.randomDbName()).collection("bar").insertOne({}); + const response = await integration.mcpClient().callTool({ + name: "find", + arguments: { database: integration.randomDbName(), collection: "non-existent" }, + }); + const content = getResponseContent(response.content); + expect(content).toEqual('Found 0 documents in the collection "non-existent":'); + }); + + describe("with existing database", () => { + beforeEach(async () => { + const mongoClient = integration.mongoClient(); + const items = Array(10) + .fill(0) + .map((_, index) => ({ + value: index, + })); + + await mongoClient.db(integration.randomDbName()).collection("foo").insertMany(items); + }); + + const testCases: { + name: string; + filter?: any; + limit?: number; + projection?: any; + sort?: any; + expected: any[]; + }[] = [ + { + name: "returns all documents when no filter is provided", + expected: Array(10) + .fill(0) + .map((_, index) => ({ _id: expect.any(Object), value: index })), + }, + { + name: "returns documents matching the filter", + filter: { value: { $gt: 5 } }, + expected: Array(4) + .fill(0) + .map((_, index) => ({ _id: expect.any(Object), value: index + 6 })), + }, + { + name: "returns documents matching the filter with projection", + filter: { value: { $gt: 5 } }, + projection: { value: 1, _id: 0 }, + expected: Array(4) + .fill(0) + .map((_, index) => ({ value: index + 6 })), + }, + { + name: "returns documents matching the filter with limit", + filter: { value: { $gt: 5 } }, + limit: 2, + expected: [ + { _id: expect.any(Object), value: 6 }, + { _id: expect.any(Object), value: 7 }, + ], + }, + { + name: "returns documents matching the filter with sort", + filter: {}, + sort: { value: -1 }, + expected: Array(10) + .fill(0) + .map((_, index) => ({ _id: expect.any(Object), value: index })) + .reverse(), + }, + ]; + + for (const { name, filter, limit, projection, sort, expected } of testCases) { + it(name, async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "find", + arguments: { + database: integration.randomDbName(), + collection: "foo", + filter, + limit, + projection, + sort, + }, + }); + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(expected.length + 1); + expect(elements[0].text).toEqual(`Found ${expected.length} documents in the collection "foo":`); + + for (let i = 0; i < expected.length; i++) { + expect(JSON.parse(elements[i + 1].text)).toEqual(expected[i]); + } + }); + } + + it("returns all documents when no filter is provided", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "find", + arguments: { database: integration.randomDbName(), collection: "foo" }, + }); + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(11); + expect(elements[0].text).toEqual('Found 10 documents in the collection "foo":'); + + for (let i = 0; i < 10; i++) { + expect(JSON.parse(elements[i + 1].text).value).toEqual(i); + } + }); + }); + + validateAutoConnectBehavior(integration, "find", () => { + return { + args: { database: integration.randomDbName(), collection: "coll1" }, + expectedResponse: 'Found 0 documents in the collection "coll1":', + }; + }); +});