mcp-mongo-server 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/build/index.js +1764 -1579
  3. package/package.json +3 -3
package/build/index.js CHANGED
@@ -176,6 +176,63 @@ function resourceSelectors(uri) {
176
176
  return selectors;
177
177
  }
178
178
 
179
+ // src/query-limits.ts
180
+ function readQueryLimits(env) {
181
+ function positiveInteger(key) {
182
+ const raw = env[key];
183
+ if (raw === void 0) return void 0;
184
+ const value = Number(raw);
185
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value <= 0) {
186
+ throw new Error(`${key} must be a positive integer`);
187
+ }
188
+ return value;
189
+ }
190
+ const bytes = positiveInteger("MCP_MONGODB_MAX_RESULT_BYTES");
191
+ if (bytes === 1) {
192
+ throw new Error("MCP_MONGODB_MAX_RESULT_BYTES must be at least 2 bytes");
193
+ }
194
+ return {
195
+ documents: positiveInteger("MCP_MONGODB_MAX_RESULT_DOCUMENTS"),
196
+ bytes,
197
+ timeoutMS: positiveInteger("MCP_MONGODB_QUERY_TIMEOUT_MS")
198
+ };
199
+ }
200
+ async function readResultPage(cursor, limits, pageSize = Infinity, signal) {
201
+ const results = [];
202
+ const documentCap = limits.documents ?? Infinity;
203
+ const maxRows = Math.min(documentCap, pageSize);
204
+ let bytes = 2;
205
+ try {
206
+ while (true) {
207
+ signal?.throwIfAborted();
208
+ const row = await cursor.next();
209
+ signal?.throwIfAborted();
210
+ if (row === null) return { results, hasMore: false };
211
+ if (results.length === maxRows) {
212
+ return {
213
+ results,
214
+ hasMore: true,
215
+ ...documentCap < pageSize ? { truncated: "documents" } : {}
216
+ };
217
+ }
218
+ if (limits.bytes !== void 0) {
219
+ bytes += Buffer.byteLength(JSON.stringify(row), "utf8") + (results.length ? 1 : 0);
220
+ if (bytes > limits.bytes) {
221
+ if (!results.length) {
222
+ throw new Error(
223
+ "First result exceeds the byte limit. Use a narrower projection or pipeline."
224
+ );
225
+ }
226
+ return { results, hasMore: true, truncated: "bytes" };
227
+ }
228
+ }
229
+ results.push(row);
230
+ }
231
+ } finally {
232
+ await cursor.close();
233
+ }
234
+ }
235
+
179
236
  // src/server.ts
180
237
  import {
181
238
  ProtocolError as ProtocolError4,
@@ -184,1742 +241,1862 @@ import {
184
241
  } from "@modelcontextprotocol/server";
185
242
 
186
243
  // src/schemas/call.ts
187
- import { ProtocolError as ProtocolError2, ProtocolErrorCode as ProtocolErrorCode2 } from "@modelcontextprotocol/server";
244
+ import { ProtocolError as ProtocolError3, ProtocolErrorCode as ProtocolErrorCode3 } from "@modelcontextprotocol/server";
245
+ import { ObjectId as ObjectId2 } from "mongodb";
246
+
247
+ // src/schemas/resource.ts
248
+ import {
249
+ ProtocolError as ProtocolError2,
250
+ ProtocolErrorCode as ProtocolErrorCode2
251
+ } from "@modelcontextprotocol/server";
188
252
  import { ObjectId } from "mongodb";
189
- var COLLECTION_OPERATIONS = [
190
- "query",
191
- "aggregate",
192
- "update",
193
- "insert",
194
- "createIndex",
195
- "count"
196
- ];
197
- var WRITE_OPERATIONS = ["update", "insert", "createIndex"];
198
- var MAX_OBJECT_DEPTH = 100;
199
- var WRITE_AGG_STAGES = /* @__PURE__ */ new Set(["$out", "$merge"]);
200
- var SERVER_JS_AGG_OPERATORS = /* @__PURE__ */ new Set([
201
- "$function",
202
- "$accumulator",
203
- "$where"
204
- ]);
205
- var READONLY_FORBIDDEN_AGG_OPERATORS = /* @__PURE__ */ new Set([
206
- ...WRITE_AGG_STAGES,
207
- ...SERVER_JS_AGG_OPERATORS
208
- ]);
209
- function findAggOperator(value, operators, depth = 0) {
210
- if (depth > MAX_OBJECT_DEPTH) {
211
- throw new Error(
212
- `Object nesting exceeds the maximum depth of ${MAX_OBJECT_DEPTH}`
253
+
254
+ // src/utils/pagination.ts
255
+ var DEFAULT_PAGE_SIZE = 20;
256
+ function encodeCursor(offset) {
257
+ const data = { offset };
258
+ return Buffer.from(JSON.stringify(data)).toString("base64");
259
+ }
260
+ function decodeCursor(cursor) {
261
+ if (!cursor) return 0;
262
+ try {
263
+ const data = JSON.parse(
264
+ Buffer.from(cursor, "base64").toString("utf-8")
213
265
  );
266
+ return typeof data.offset === "number" && data.offset >= 0 ? data.offset : 0;
267
+ } catch {
268
+ return 0;
214
269
  }
270
+ }
271
+ function paginate(items, cursor, pageSize = DEFAULT_PAGE_SIZE) {
272
+ const offset = decodeCursor(cursor);
273
+ const page = items.slice(offset, offset + pageSize);
274
+ const nextOffset = offset + pageSize;
275
+ const nextCursor = nextOffset < items.length ? encodeCursor(nextOffset) : void 0;
276
+ return { items: page, nextCursor };
277
+ }
278
+
279
+ // src/schemas/resource.ts
280
+ function detectMongoType(value) {
281
+ if (value === null) return "null";
282
+ if (value === void 0) return "undefined";
283
+ if (value instanceof ObjectId) return "ObjectId";
284
+ if (value instanceof Date) return "Date";
215
285
  if (Array.isArray(value)) {
216
- for (const item of value) {
217
- const found = findAggOperator(item, operators, depth + 1);
218
- if (found) return found;
286
+ if (value.length === 0) return "Array";
287
+ const elementTypes = new Set(value.map((item) => detectMongoType(item)));
288
+ if (elementTypes.size === 1) {
289
+ return `Array<${Array.from(elementTypes)[0]}>`;
219
290
  }
220
- return null;
291
+ return "Array<mixed>";
221
292
  }
222
- if (value && typeof value === "object") {
223
- for (const [key, nested] of Object.entries(value)) {
224
- if (operators.has(key)) {
225
- return key;
293
+ if (typeof value === "object") {
294
+ return "Document";
295
+ }
296
+ return typeof value;
297
+ }
298
+ function inferSchemaFromSamples(documents) {
299
+ if (!documents || documents.length === 0) {
300
+ return { fields: [] };
301
+ }
302
+ const fieldMap = /* @__PURE__ */ new Map();
303
+ for (const doc of documents) {
304
+ for (const [key, value] of Object.entries(doc)) {
305
+ if (!fieldMap.has(key)) {
306
+ fieldMap.set(key, {
307
+ name: key,
308
+ types: /* @__PURE__ */ new Set([detectMongoType(value)]),
309
+ nullable: false,
310
+ // Store sample values for complex types
311
+ samples: [value]
312
+ });
313
+ } else {
314
+ const fieldInfo = fieldMap.get(key);
315
+ fieldInfo.types.add(detectMongoType(value));
316
+ if (fieldInfo.samples.length < 3 && !fieldInfo.samples.some(
317
+ (sample) => JSON.stringify(sample) === JSON.stringify(value)
318
+ )) {
319
+ fieldInfo.samples.push(value);
320
+ }
226
321
  }
227
- const found = findAggOperator(nested, operators, depth + 1);
228
- if (found) return found;
229
322
  }
230
323
  }
231
- return null;
232
- }
233
- function assertNoServerJs(value) {
234
- const operator = findAggOperator(value, SERVER_JS_AGG_OPERATORS);
235
- if (operator) {
236
- throw new Error(
237
- `Server-side JavaScript operator '${operator}' is disabled by the current server policy.`
238
- );
324
+ for (const doc of documents) {
325
+ for (const [key] of fieldMap.entries()) {
326
+ if (!(key in doc)) {
327
+ const fieldInfo = fieldMap.get(key);
328
+ fieldInfo.nullable = true;
329
+ }
330
+ }
239
331
  }
240
- }
241
- function findCrossDbTarget(pipeline, connectedDb) {
242
- const foreignDb = (target) => {
243
- if (target && typeof target === "object" && "db" in target) {
244
- const db = target.db;
245
- if (typeof db === "string" && db && db !== connectedDb) return db;
332
+ for (const [key, fieldInfo] of fieldMap.entries()) {
333
+ if (fieldInfo.types.has("Document")) {
334
+ const nestedDocs = documents.filter(
335
+ (doc) => doc[key] && typeof doc[key] === "object" && !Array.isArray(doc[key])
336
+ ).map((doc) => doc[key]);
337
+ if (nestedDocs.length > 0) {
338
+ fieldInfo.nestedSchema = inferSchemaFromSamples(nestedDocs);
339
+ }
246
340
  }
247
- return null;
248
- };
249
- for (const stage of pipeline) {
250
- if (!stage || typeof stage !== "object") continue;
251
- const s = stage;
252
- const out = foreignDb(s.$out);
253
- if (out) return out;
254
- const merge = s.$merge;
255
- const mergeInto = foreignDb(merge?.into ?? s.$merge);
256
- if (mergeInto) return mergeInto;
257
- const lookup = s.$lookup;
258
- const lookupFrom = foreignDb(lookup?.from);
259
- if (lookupFrom) return lookupFrom;
260
341
  }
261
- return null;
342
+ const fields = Array.from(fieldMap.values()).map((fieldInfo) => {
343
+ const result = {
344
+ name: fieldInfo.name,
345
+ types: Array.from(fieldInfo.types),
346
+ nullable: fieldInfo.nullable,
347
+ prevalence: `${Math.round(
348
+ documents.filter((doc) => fieldInfo.name in doc).length / documents.length * 100
349
+ )}%`,
350
+ examples: []
351
+ };
352
+ if (fieldInfo.nestedSchema) {
353
+ result.nestedSchema = fieldInfo.nestedSchema;
354
+ }
355
+ const sampleValues = fieldInfo.samples.map((sample) => {
356
+ if (sample instanceof ObjectId) return sample.toString();
357
+ if (sample instanceof Date) return sample.toISOString();
358
+ if (typeof sample === "object") {
359
+ return Array.isArray(sample) ? "[...]" : "{...}";
360
+ }
361
+ return sample;
362
+ });
363
+ result.examples = sampleValues;
364
+ return result;
365
+ });
366
+ return { fields };
262
367
  }
263
- async function handleCallToolRequest({
368
+ async function handleReadResourceRequest({
264
369
  request,
265
370
  client,
266
371
  db,
267
372
  isReadOnlyMode,
268
- allowCrossDb = false,
269
- allowServerJs = false,
270
- connections: connections2,
271
- signal
373
+ signal,
374
+ sendProgress
272
375
  }) {
273
- const { name, arguments: args = {} } = request.params;
274
- const operation = name;
275
- const objectIdMode = args.objectIdMode || "auto";
276
- const filteredArgs = {};
277
- for (const [key, value] of Object.entries(args)) {
278
- if (key !== "objectIdMode") {
279
- filteredArgs[key] = value;
376
+ let collectionName;
377
+ try {
378
+ const url = new URL(request.params.uri);
379
+ if (url.protocol !== "mongodb:") {
380
+ throw new ProtocolError2(
381
+ ProtocolErrorCode2.InvalidParams,
382
+ `Unsupported resource URI scheme '${url.protocol}': expected 'mongodb:'`
383
+ );
280
384
  }
385
+ collectionName = decodeURIComponent(url.pathname.replace(/^\//, ""));
386
+ } catch (error) {
387
+ if (error instanceof ProtocolError2) throw error;
388
+ throw new ProtocolError2(
389
+ ProtocolErrorCode2.InvalidParams,
390
+ `Invalid resource URI: ${request.params.uri}`
391
+ );
281
392
  }
282
- if (args.sort) {
283
- args.sort = parseSort(args.sort);
393
+ if (!collectionName) {
394
+ throw new ProtocolError2(
395
+ ProtocolErrorCode2.InvalidParams,
396
+ "Resource URI is missing a collection name"
397
+ );
284
398
  }
285
- Object.assign(args, filteredArgs);
286
- if (!connections2 || name !== "listConnections" && name !== "listDatabases") {
287
- validateOperation(operation);
399
+ if (collectionName.toLowerCase().startsWith("system.")) {
400
+ throw new ProtocolError2(
401
+ ProtocolErrorCode2.InvalidParams,
402
+ "Access to system collections is not allowed"
403
+ );
288
404
  }
289
405
  try {
406
+ const schema = await buildCollectionSchema(
407
+ db,
408
+ db.collection(collectionName),
409
+ signal,
410
+ sendProgress
411
+ );
412
+ return {
413
+ contents: [
414
+ {
415
+ uri: request.params.uri,
416
+ mimeType: "application/json",
417
+ text: JSON.stringify(schema, null, 2)
418
+ }
419
+ ]
420
+ };
421
+ } catch (error) {
290
422
  signal?.throwIfAborted();
291
- if (connections2 && name === "listConnections") {
292
- return formatResponse({ connections: connections2.list() });
293
- }
294
- if (connections2 && name === "listDatabases") {
295
- return formatResponse({
296
- databases: await connections2.listDatabases(args.connectionId)
297
- });
423
+ if (error instanceof Error) {
424
+ throw new Error(
425
+ `Failed to read collection ${collectionName}: ${error.message}`
426
+ );
298
427
  }
299
- if (connections2 && operation !== "convertTime") {
300
- const target = await connections2.resolve(args);
301
- db = target.db;
302
- isReadOnlyMode = target.isReadOnlyMode;
303
- if (connections2.named && operation === "aggregate" && Array.isArray(args.pipeline)) {
304
- const allowed = connections2.allowedDatabases(target.connectionId);
305
- checkNamedAggregationDatabases(
306
- args.pipeline,
307
- db.databaseName,
308
- allowCrossDb ? allowed : [db.databaseName]
309
- );
310
- }
311
- }
312
- checkReadOnlyMode(operation, isReadOnlyMode);
313
- let collection = null;
314
- if (COLLECTION_OPERATIONS.includes(operation)) {
315
- const collectionName = args.collection;
316
- if (!collectionName) {
317
- throw new Error(
318
- `Collection name is required for '${operation}' operation`
319
- );
320
- }
321
- collection = db.collection(collectionName);
322
- validateCollection(collection);
323
- }
324
- signal?.throwIfAborted();
325
- return await executeOperation(
326
- operation,
327
- collection,
328
- db,
329
- isReadOnlyMode,
330
- allowCrossDb,
331
- allowServerJs,
332
- args,
333
- objectIdMode,
334
- signal
335
- );
336
- } catch (error) {
337
- if (error instanceof ProtocolError2 || signal?.aborted) {
338
- throw error;
339
- }
340
- return toolExecutionError(
341
- error instanceof Error ? error.message : "Unknown error"
428
+ throw new Error(
429
+ `Failed to read collection ${collectionName}: Unknown error`
342
430
  );
343
431
  }
344
432
  }
345
- function checkNamedAggregationDatabases(pipeline, database, allowed, depth = 0) {
346
- if (depth > MAX_OBJECT_DEPTH)
347
- throw new Error("Aggregation pipeline nesting is too deep");
348
- for (const stage of pipeline) {
349
- if (!stage || typeof stage !== "object") continue;
350
- const { $out, $merge, $lookup, $unionWith, $facet } = stage;
351
- for (const target of [$out, $merge, $lookup, $unionWith]) {
352
- if (!target || typeof target !== "object") continue;
353
- const value = target;
354
- for (const reference of [value, value.into, value.from]) {
355
- if (!reference || typeof reference !== "object" || !("db" in reference))
356
- continue;
357
- if (typeof reference.db !== "string" || !allowed.includes(reference.db)) {
358
- throw new Error(
359
- `Aggregation references a database outside the allowed scope of '${database}'`
360
- );
361
- }
362
- }
363
- if (Array.isArray(value.pipeline))
364
- checkNamedAggregationDatabases(
365
- value.pipeline,
366
- database,
367
- allowed,
368
- depth + 1
369
- );
370
- }
371
- if ($facet && typeof $facet === "object") {
372
- for (const nested of Object.values($facet)) {
373
- if (Array.isArray(nested))
374
- checkNamedAggregationDatabases(nested, database, allowed, depth + 1);
375
- }
376
- }
377
- }
378
- }
379
- async function executeOperation(operation, collection, db, isReadOnlyMode, allowCrossDb, allowServerJs, args, objectIdMode, signal) {
380
- switch (operation) {
381
- case "query":
382
- return handleQuery(
383
- collection,
384
- args,
385
- objectIdMode,
386
- allowServerJs && !isReadOnlyMode,
387
- signal
388
- );
389
- case "aggregate":
390
- return handleAggregate(
391
- collection,
392
- args,
393
- objectIdMode,
394
- isReadOnlyMode,
395
- allowCrossDb,
396
- allowServerJs,
397
- signal
398
- );
399
- case "update":
400
- return handleUpdate(
401
- collection,
402
- args,
403
- objectIdMode,
404
- allowServerJs,
405
- signal
406
- );
407
- case "serverInfo":
408
- return handleServerInfo(db, isReadOnlyMode, args, signal);
409
- case "insert":
410
- return handleInsert(collection, args, objectIdMode, signal);
411
- case "createIndex":
412
- return handleCreateIndex(collection, args, objectIdMode, signal);
413
- case "count":
414
- return handleCount(
415
- collection,
416
- args,
417
- objectIdMode,
418
- allowServerJs && !isReadOnlyMode,
419
- signal
420
- );
421
- case "listCollections":
422
- return handleListCollections(db, args, objectIdMode, signal);
423
- case "convertTime":
424
- return handleConvertTime(args);
425
- default:
426
- throw new ProtocolError2(
427
- ProtocolErrorCode2.InvalidParams,
428
- `Unknown tool: ${operation}`
429
- );
430
- }
431
- }
432
- function validateOperation(operation) {
433
- const validOperations = [
434
- "query",
435
- "aggregate",
436
- "update",
437
- "serverInfo",
438
- "insert",
439
- "createIndex",
440
- "count",
441
- "listCollections",
442
- "convertTime"
443
- ];
444
- if (!validOperations.includes(operation)) {
445
- throw new ProtocolError2(
446
- ProtocolErrorCode2.InvalidParams,
447
- `Unknown tool: ${operation}`
433
+ async function buildCollectionSchema(db, collection, signal, sendProgress) {
434
+ const collectionName = collection.collectionName;
435
+ const sampleSize = 100;
436
+ let sampleDocuments = [];
437
+ await sendProgress?.(1, 4, "Sampling documents");
438
+ try {
439
+ sampleDocuments = await collection.aggregate([{ $sample: { size: sampleSize } }], { signal }).toArray();
440
+ } catch (sampleError) {
441
+ signal?.throwIfAborted();
442
+ console.warn(
443
+ `$sample aggregation failed for ${collectionName}, falling back to sequential scan: ${sampleError}`
448
444
  );
445
+ signal?.throwIfAborted();
446
+ sampleDocuments = await collection.find({}, { signal }).limit(sampleSize).toArray();
449
447
  }
450
- }
451
- function validateCollection(collection) {
452
- if (!collection.collectionName) {
453
- throw new Error("Collection name cannot be empty");
454
- }
455
- if (collection.collectionName.toLowerCase().startsWith("system.")) {
456
- throw new Error("Access to system collections is not allowed");
457
- }
458
- }
459
- function checkReadOnlyMode(operation, isReadOnlyMode) {
460
- if (isReadOnlyMode && WRITE_OPERATIONS.includes(operation)) {
461
- throw new Error(
462
- `ReadonlyError: Operation '${operation}' is not allowed in read-only mode`
448
+ await sendProgress?.(2, 4, "Getting indexes");
449
+ signal?.throwIfAborted();
450
+ const indexOptions = {
451
+ signal
452
+ };
453
+ const indexes = await collection.indexes(indexOptions);
454
+ const inferredSchema = inferSchemaFromSamples(sampleDocuments);
455
+ await sendProgress?.(3, 4, "Counting documents");
456
+ let documentCount = null;
457
+ try {
458
+ signal?.throwIfAborted();
459
+ documentCount = await collection.countDocuments(
460
+ {},
461
+ { timeoutMS: 5e3, signal }
462
+ );
463
+ } catch (countError) {
464
+ signal?.throwIfAborted();
465
+ console.warn(
466
+ `Count operation failed or timed out for ${collectionName}: ${countError}`
463
467
  );
464
- }
465
- }
466
- function parseSort(sort) {
467
- if (!sort) return null;
468
- if (typeof sort !== "object" || sort === null || Array.isArray(sort)) {
469
- return null;
470
- }
471
- const validSort = {};
472
- for (const [key, value] of Object.entries(sort)) {
473
- if (typeof value === "number" && (value === 1 || value === -1)) {
474
- validSort[key] = value;
475
- }
476
- }
477
- return Object.keys(validSort).length > 0 ? validSort : null;
478
- }
479
- function parseFilter(filter, objectIdMode = "auto") {
480
- if (!filter) {
481
- return {};
482
- }
483
- if (typeof filter === "string") {
484
468
  try {
485
- return processObjectIdInFilter(JSON.parse(filter), objectIdMode);
469
+ signal?.throwIfAborted();
470
+ const stats = await db.command(
471
+ { collStats: collectionName },
472
+ { timeoutMS: 5e3, signal }
473
+ );
474
+ documentCount = stats.count;
486
475
  } catch {
487
- throw new Error("Invalid filter format: must be a valid JSON object");
476
+ signal?.throwIfAborted();
477
+ documentCount = "unknown (count operation timed out)";
488
478
  }
489
479
  }
490
- if (typeof filter === "object" && filter !== null && !Array.isArray(filter)) {
491
- return processObjectIdInFilter(
492
- filter,
493
- objectIdMode
494
- );
495
- }
496
- throw new Error("Query filter must be a plain object or ObjectId");
497
- }
498
- function isObjectIdField(fieldName) {
499
- const lowerFieldName = fieldName.toLowerCase();
500
- return lowerFieldName === "_id" || lowerFieldName === "id" || lowerFieldName.endsWith("id") || lowerFieldName.endsWith("_id");
480
+ await sendProgress?.(4, 4, "Done");
481
+ return {
482
+ type: "collection",
483
+ name: collectionName,
484
+ fields: inferredSchema.fields,
485
+ indexes: indexes.map((idx) => ({
486
+ name: idx.name,
487
+ keys: idx.key
488
+ })),
489
+ documentCount,
490
+ sampleSize: sampleDocuments.length,
491
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
492
+ };
501
493
  }
502
- function processObjectIdInFilter(filter, objectIdMode = "auto", depth = 0) {
503
- if (depth > MAX_OBJECT_DEPTH) {
504
- throw new Error(
505
- `Object nesting exceeds the maximum depth of ${MAX_OBJECT_DEPTH}`
506
- );
494
+ async function handleListResourcesRequest({
495
+ request,
496
+ client,
497
+ db,
498
+ isReadOnlyMode,
499
+ signal
500
+ }) {
501
+ try {
502
+ signal?.throwIfAborted();
503
+ const collections = await db.listCollections().toArray();
504
+ const allResources = collections.map((collection) => ({
505
+ uri: `mongodb:///${encodeURIComponent(collection.name)}`,
506
+ mimeType: "application/json",
507
+ name: collection.name,
508
+ description: `MongoDB collection: ${collection.name}`
509
+ }));
510
+ const cursor = request.params?.cursor;
511
+ const { items, nextCursor } = paginate(allResources, cursor);
512
+ return {
513
+ resources: items,
514
+ ...nextCursor ? { nextCursor } : {}
515
+ };
516
+ } catch (error) {
517
+ if (error instanceof Error) {
518
+ throw new Error(`Failed to list collections: ${error.message}`);
519
+ }
520
+ throw new Error("Failed to list collections: Unknown error");
507
521
  }
508
- if (objectIdMode === "none") {
509
- const result2 = {};
510
- for (const [key, value] of Object.entries(filter)) {
511
- if (typeof value === "string" && isISODateString(value)) {
512
- result2[key] = new Date(value);
513
- } else if (typeof value === "string" && value.startsWith("ISODate(") && value.endsWith(")")) {
514
- const dateString = value.substring(8, value.length - 2);
515
- if (isISODateString(dateString)) {
516
- result2[key] = new Date(dateString);
517
- } else {
518
- result2[key] = value;
519
- }
520
- } else if (typeof value === "object" && value !== null) {
521
- if (Array.isArray(value)) {
522
- result2[key] = value.map((item) => {
523
- if (typeof item === "string" && isISODateString(item)) {
524
- return new Date(item);
525
- } else if (typeof item === "string" && item.startsWith("ISODate(") && item.endsWith(")")) {
526
- const dateString = item.substring(8, item.length - 2);
527
- return isISODateString(dateString) ? new Date(dateString) : item;
528
- }
529
- return item;
530
- });
531
- } else {
532
- result2[key] = processObjectIdInFilter(
533
- value,
534
- "none",
535
- depth + 1
536
- );
522
+ }
523
+
524
+ // src/schemas/tools.ts
525
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["update", "insert", "createIndex"]);
526
+ async function handleListToolsRequest({
527
+ request,
528
+ client,
529
+ db,
530
+ isReadOnlyMode,
531
+ signal,
532
+ connectionSelection = false,
533
+ hasWritableConnection = !isReadOnlyMode,
534
+ queryLimits = {}
535
+ }) {
536
+ const result = {
537
+ tools: [
538
+ {
539
+ name: "query",
540
+ description: "Find documents in one collection using a filter, projection, or sort. Use aggregate for grouping or joins. Request an execution plan to inspect the query.",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ collection: {
545
+ type: "string",
546
+ description: "Name of the collection to query"
547
+ },
548
+ filter: {
549
+ type: "object",
550
+ description: "MongoDB query filter. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation"
551
+ },
552
+ projection: {
553
+ type: "object",
554
+ description: "Fields to include/exclude"
555
+ },
556
+ limit: {
557
+ type: "number",
558
+ description: "Requested page size. Configured server caps may reduce the number returned.",
559
+ default: 10
560
+ },
561
+ includeTotal: {
562
+ type: "boolean",
563
+ description: "Include the exact match count. Set false to skip counting.",
564
+ default: true
565
+ },
566
+ skip: {
567
+ type: "number",
568
+ description: "Number of documents to skip before returning results",
569
+ default: 0
570
+ },
571
+ sort: {
572
+ type: "object",
573
+ description: "Sort order as a field-to-direction map, e.g. { orderDate: -1 } for descending or { name: 1 } for ascending"
574
+ },
575
+ explain: {
576
+ type: "string",
577
+ description: "Optional: Get query execution information",
578
+ enum: ["queryPlanner", "executionStats", "allPlansExecution"]
579
+ },
580
+ objectIdMode: {
581
+ type: "string",
582
+ description: "Control how 24-character hex strings are handled",
583
+ enum: ["auto", "none", "force"],
584
+ default: "auto"
585
+ }
586
+ },
587
+ required: ["collection"]
537
588
  }
538
- } else {
539
- result2[key] = value;
540
- }
541
- }
542
- return result2;
543
- }
544
- const result = {};
545
- for (const [key, value] of Object.entries(filter)) {
546
- if (typeof value === "string" && isObjectIdString(value)) {
547
- if (objectIdMode === "force" || objectIdMode === "auto" && isObjectIdField(key)) {
548
- result[key] = new ObjectId(value);
549
- } else {
550
- result[key] = value;
551
- }
552
- } else if (typeof value === "string" && isISODateString(value)) {
553
- result[key] = new Date(value);
554
- } else if (typeof value === "string" && value.startsWith("ISODate(") && value.endsWith(")")) {
555
- const dateString = value.substring(8, value.length - 2);
556
- if (isISODateString(dateString)) {
557
- result[key] = new Date(dateString);
558
- } else {
559
- result[key] = value;
560
- }
561
- } else if (typeof value === "object" && value !== null) {
562
- if (Array.isArray(value)) {
563
- result[key] = value.map((item) => {
564
- if (typeof item === "string" && isObjectIdString(item) && (objectIdMode === "force" || objectIdMode === "auto" && isObjectIdField(key))) {
565
- return new ObjectId(item);
566
- } else if (typeof item === "string" && isISODateString(item)) {
567
- return new Date(item);
568
- } else if (typeof item === "string" && item.startsWith("ISODate(") && item.endsWith(")")) {
569
- const dateString = item.substring(8, item.length - 2);
570
- return isISODateString(dateString) ? new Date(dateString) : item;
589
+ },
590
+ {
591
+ name: "aggregate",
592
+ description: "Run a MongoDB aggregation pipeline for grouping, joins, or multi-stage transformations. Request an execution plan to inspect the pipeline.",
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ collection: {
597
+ type: "string",
598
+ description: "Name of the collection to aggregate"
599
+ },
600
+ pipeline: {
601
+ type: "array",
602
+ description: "Aggregation pipeline stages. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation",
603
+ items: {
604
+ type: "object"
605
+ }
606
+ },
607
+ explain: {
608
+ type: "string",
609
+ description: "Optional: Get aggregation execution information (queryPlanner, executionStats, or allPlansExecution)",
610
+ enum: ["queryPlanner", "executionStats", "allPlansExecution"]
611
+ },
612
+ objectIdMode: {
613
+ type: "string",
614
+ description: "Control how 24-character hex strings are handled",
615
+ enum: ["auto", "none", "force"],
616
+ default: "auto"
617
+ }
618
+ },
619
+ required: ["collection", "pipeline"]
620
+ }
621
+ },
622
+ {
623
+ name: "update",
624
+ description: "Modify matching documents using operators such as $set, $unset, or $inc. Enable multi to update all matches or upsert to create a document when the filter matches none.",
625
+ inputSchema: {
626
+ type: "object",
627
+ properties: {
628
+ collection: {
629
+ type: "string",
630
+ description: "Name of the collection to update"
631
+ },
632
+ filter: {
633
+ type: "object",
634
+ description: "Filter to select documents to update. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation"
635
+ },
636
+ update: {
637
+ type: "object",
638
+ description: "Update operations to apply ($set, $unset, $inc, etc.)"
639
+ },
640
+ upsert: {
641
+ type: "boolean",
642
+ description: "Create a new document if no documents match the filter"
643
+ },
644
+ multi: {
645
+ type: "boolean",
646
+ description: "Update multiple documents that match the filter"
647
+ },
648
+ objectIdMode: {
649
+ type: "string",
650
+ description: "Control how 24-character hex strings are handled",
651
+ enum: ["auto", "none", "force"],
652
+ default: "auto"
653
+ }
654
+ },
655
+ required: ["collection", "filter", "update"]
656
+ }
657
+ },
658
+ {
659
+ name: "serverInfo",
660
+ description: "Inspect MongoDB server metadata such as version and storage engine. Enable includeDebugInfo for additional diagnostics.",
661
+ inputSchema: {
662
+ type: "object",
663
+ properties: {
664
+ includeDebugInfo: {
665
+ type: "boolean",
666
+ description: "Include additional debug information about the server"
667
+ }
571
668
  }
572
- return item;
573
- });
574
- } else {
575
- result[key] = processObjectIdInFilter(
576
- value,
577
- objectIdMode,
578
- depth + 1
579
- );
580
- }
581
- } else {
582
- result[key] = value;
583
- }
584
- }
585
- return result;
586
- }
587
- function isObjectIdString(str) {
588
- return /^[0-9a-fA-F]{24}$/.test(str);
589
- }
590
- function isISODateString(str) {
591
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/.test(
592
- str
593
- );
594
- }
595
- function formatResponse(data) {
596
- return {
597
- content: [
669
+ }
670
+ },
598
671
  {
599
- type: "text",
600
- text: JSON.stringify(data, null, 2)
601
- }
602
- ]
603
- };
604
- }
605
- function toolExecutionError(message) {
606
- return {
607
- content: [{ type: "text", text: message }],
608
- isError: true
609
- };
610
- }
611
- function handleError(error, operation, collectionName) {
612
- if (error instanceof Error && error.name === "AbortError") {
613
- throw error;
614
- }
615
- const context = collectionName ? `collection ${collectionName}` : "operation";
616
- const message = error instanceof Error ? error.message : "Unknown error";
617
- return toolExecutionError(`Failed to ${operation} ${context}: ${message}`);
618
- }
619
- async function handleQuery(collection, args, objectIdMode = "auto", allowServerJs = false, signal) {
620
- if (!collection) {
621
- throw new Error("Collection is required for query operation");
622
- }
623
- const { filter, projection, explain, sort } = args;
624
- const limit = args.limit || 10;
625
- const skip = args.skip || 0;
626
- const queryFilter = parseFilter(filter, objectIdMode);
627
- if (!allowServerJs) {
628
- assertNoServerJs(queryFilter);
629
- }
630
- try {
631
- if (explain) {
632
- signal?.throwIfAborted();
633
- const explainResult = await collection.find(queryFilter, {
634
- projection,
635
- limit,
636
- skip,
637
- sort
638
- }).explain(explain);
639
- return formatResponse(explainResult);
640
- }
641
- signal?.throwIfAborted();
642
- const [total, results] = await Promise.all([
643
- collection.countDocuments(queryFilter),
644
- collection.find(queryFilter, {
645
- projection,
646
- limit,
647
- skip,
648
- sort
649
- }).toArray()
650
- ]);
651
- return formatResponse({
652
- results,
653
- metadata: {
654
- total,
655
- returned: results.length,
656
- skip,
657
- limit,
658
- hasMore: skip + results.length < total
659
- }
660
- });
661
- } catch (error) {
662
- return handleError(error, "query", collection.collectionName);
663
- }
664
- }
665
- async function handleAggregate(collection, args, objectIdMode = "auto", isReadOnlyMode = false, allowCrossDb = false, allowServerJs = false, signal) {
666
- if (!collection) {
667
- throw new Error("Collection is required for aggregate operation");
668
- }
669
- const { pipeline, explain } = args;
670
- if (!Array.isArray(pipeline)) {
671
- throw new Error("Pipeline must be an array");
672
- }
673
- if (isReadOnlyMode) {
674
- const forbidden = findAggOperator(
675
- pipeline,
676
- READONLY_FORBIDDEN_AGG_OPERATORS
677
- );
678
- if (forbidden) {
679
- throw new Error(
680
- `ReadonlyError: Aggregation operator '${forbidden}' is not allowed in read-only mode`
681
- );
682
- }
683
- }
684
- if (!allowServerJs) {
685
- assertNoServerJs(pipeline);
686
- }
687
- if (!allowCrossDb) {
688
- const foreignDb = findCrossDbTarget(pipeline, collection.dbName);
689
- if (foreignDb) {
690
- throw new Error(
691
- `Cross-database access to '${foreignDb}' is not allowed (connected database is '${collection.dbName}'). Enable it with --allow-cross-db or MCP_MONGODB_ALLOW_CROSS_DB=true.`
692
- );
693
- }
694
- }
695
- const processedPipeline = pipeline.map((stage) => {
696
- if (typeof stage === "object" && stage !== null) {
697
- return processObjectIdInFilter(
698
- stage,
699
- objectIdMode
700
- );
701
- }
702
- return stage;
703
- });
704
- try {
705
- if (explain) {
706
- signal?.throwIfAborted();
707
- const explainResult = await collection.aggregate(processedPipeline, {
708
- explain: {
709
- verbosity: explain
672
+ name: "insert",
673
+ description: "Insert new documents into a MongoDB collection. Use update to modify existing records.",
674
+ inputSchema: {
675
+ type: "object",
676
+ properties: {
677
+ collection: {
678
+ type: "string",
679
+ description: "Name of the collection to insert into"
680
+ },
681
+ documents: {
682
+ type: "array",
683
+ description: "Array of documents to insert",
684
+ items: { type: "object" }
685
+ },
686
+ ordered: {
687
+ type: "boolean",
688
+ description: "If true, perform ordered insert. If false, insert unordered"
689
+ },
690
+ writeConcern: {
691
+ type: "object",
692
+ description: "Write concern for the insert operation"
693
+ },
694
+ bypassDocumentValidation: {
695
+ type: "boolean",
696
+ description: "Allow insert to bypass schema validation"
697
+ },
698
+ objectIdMode: {
699
+ type: "string",
700
+ description: "Control how 24-character hex strings are handled",
701
+ enum: ["auto", "none", "force"],
702
+ default: "auto"
703
+ }
704
+ },
705
+ required: ["collection", "documents"]
710
706
  }
711
- }).toArray();
712
- return formatResponse(explainResult);
713
- }
714
- signal?.throwIfAborted();
715
- const results = await collection.aggregate(processedPipeline).toArray();
716
- return formatResponse({
717
- results,
718
- metadata: {
719
- returned: results.length
720
- }
721
- });
722
- } catch (error) {
723
- return handleError(error, "aggregate", collection.collectionName);
724
- }
725
- }
726
- async function handleUpdate(collection, args, objectIdMode = "auto", allowServerJs = false, signal) {
727
- if (!collection) {
728
- throw new Error("Collection is required for update operation");
729
- }
730
- const { filter, update, upsert, multi } = args;
731
- const queryFilter = parseFilter(filter, objectIdMode);
732
- if (!allowServerJs) {
733
- assertNoServerJs(queryFilter);
734
- }
735
- let processedUpdate = update;
736
- if (update && typeof update === "object" && !Array.isArray(update)) {
737
- processedUpdate = processObjectIdInFilter(
738
- update,
739
- objectIdMode
740
- );
741
- }
742
- if (!processedUpdate || typeof processedUpdate !== "object" || Array.isArray(processedUpdate)) {
743
- throw new Error("Update must be a valid MongoDB update document");
744
- }
745
- const validUpdateOperators = [
746
- "$set",
747
- "$unset",
748
- "$inc",
749
- "$push",
750
- "$pull",
751
- "$addToSet",
752
- "$pop",
753
- "$rename",
754
- "$mul"
755
- ];
756
- const hasValidOperator = Object.keys(processedUpdate).some(
757
- (key) => validUpdateOperators.includes(key)
707
+ },
708
+ {
709
+ name: "createIndex",
710
+ description: "Create indexes to improve query performance, enforce uniqueness, or expire documents with a TTL index.",
711
+ inputSchema: {
712
+ type: "object",
713
+ properties: {
714
+ collection: {
715
+ type: "string",
716
+ description: "Name of the collection to create indexes on"
717
+ },
718
+ indexes: {
719
+ type: "array",
720
+ description: "Array of index specifications",
721
+ items: {
722
+ type: "object",
723
+ properties: {
724
+ key: {
725
+ type: "object",
726
+ description: "Index key pattern, e.g. { field: 1 }"
727
+ },
728
+ name: {
729
+ type: "string",
730
+ description: "Optional: Name of the index"
731
+ },
732
+ unique: {
733
+ type: "boolean",
734
+ description: "Optional: Creates a unique index"
735
+ },
736
+ sparse: {
737
+ type: "boolean",
738
+ description: "Optional: Creates a sparse index"
739
+ },
740
+ background: {
741
+ type: "boolean",
742
+ description: "Optional: Builds index in background"
743
+ },
744
+ expireAfterSeconds: {
745
+ type: "number",
746
+ description: "TTL in seconds for documents"
747
+ },
748
+ partialFilterExpression: {
749
+ type: "object",
750
+ description: "Filter expression for partial indexes"
751
+ }
752
+ },
753
+ required: ["key"]
754
+ }
755
+ },
756
+ writeConcern: {
757
+ type: "object",
758
+ description: "Write concern for index creation"
759
+ },
760
+ commitQuorum: {
761
+ type: ["string", "number"],
762
+ description: "Replica-set commit quorum as a member count or a mode such as majority"
763
+ },
764
+ objectIdMode: {
765
+ type: "string",
766
+ description: "Control how 24-character hex strings are handled",
767
+ enum: ["auto", "none", "force"],
768
+ default: "auto"
769
+ }
770
+ },
771
+ required: ["collection", "indexes"]
772
+ }
773
+ },
774
+ {
775
+ name: "count",
776
+ description: "Count documents matching a filter in one collection. Use aggregate for counts grouped by field.",
777
+ inputSchema: {
778
+ type: "object",
779
+ properties: {
780
+ collection: { type: "string", description: "Collection name" },
781
+ query: { type: "object", description: "Query filter to count" },
782
+ limit: { type: "integer", description: "Max documents to count" },
783
+ skip: {
784
+ type: "integer",
785
+ description: "Docs to skip before counting"
786
+ },
787
+ hint: { type: "object", description: "Index hint" },
788
+ readConcern: { type: "object", description: "Read concern option" },
789
+ maxTimeMS: { type: "integer", description: "Max execution time" },
790
+ collation: {
791
+ type: "object",
792
+ description: "Collation rules for comparison"
793
+ },
794
+ objectIdMode: {
795
+ type: "string",
796
+ description: "Control how 24-character hex strings are handled",
797
+ enum: ["auto", "none", "force"],
798
+ default: "auto"
799
+ }
800
+ },
801
+ required: ["collection"]
802
+ }
803
+ },
804
+ {
805
+ name: "listCollections",
806
+ description: "List collections in the connected database. Filter by name or other collection properties and request names only for a compact result.",
807
+ inputSchema: {
808
+ type: "object",
809
+ properties: {
810
+ nameOnly: {
811
+ type: "boolean",
812
+ description: "If true, return only collection names"
813
+ },
814
+ filter: {
815
+ type: "object",
816
+ description: "Filter for collections"
817
+ },
818
+ skip: {
819
+ type: "number",
820
+ description: "Number of collections to skip before returning results",
821
+ default: 0
822
+ },
823
+ limit: {
824
+ type: "number",
825
+ description: "Maximum number of collections to return",
826
+ default: 20
827
+ },
828
+ objectIdMode: {
829
+ type: "string",
830
+ description: "Control how 24-character hex strings are handled",
831
+ enum: ["auto", "none", "force"],
832
+ default: "auto"
833
+ }
834
+ }
835
+ }
836
+ },
837
+ ...[
838
+ ["listIndexes", "List a collection's indexes, keys, and options."],
839
+ [
840
+ "collectionSchema",
841
+ "Infer a collection schema from sample documents and include its indexes."
842
+ ]
843
+ ].map(([name, description]) => ({
844
+ name,
845
+ description,
846
+ inputSchema: {
847
+ type: "object",
848
+ properties: {
849
+ collection: {
850
+ type: "string",
851
+ description: "Name of the collection to inspect"
852
+ }
853
+ },
854
+ required: ["collection"]
855
+ }
856
+ })),
857
+ {
858
+ name: "convertTime",
859
+ description: "Convert a Unix timestamp or date string to multiple formats (UTC ISO 8601, GMT, Unix seconds/milliseconds) and report the server's current time and timezone. Useful for building unambiguous date queries when the server and user are in different timezones. Omit 'input' to get the current time.",
860
+ inputSchema: {
861
+ type: "object",
862
+ properties: {
863
+ input: {
864
+ type: "string",
865
+ description: "A Unix timestamp (seconds or milliseconds, as a number or numeric string) or a date string (ISO 8601, with or without a timezone offset). Omit to use the current server time."
866
+ }
867
+ }
868
+ }
869
+ }
870
+ ]
871
+ };
872
+ const configuredLimits = [
873
+ queryLimits.documents !== void 0 && `${queryLimits.documents} documents`,
874
+ queryLimits.bytes !== void 0 && `${queryLimits.bytes} bytes in the results array`,
875
+ queryLimits.timeoutMS !== void 0 && `${queryLimits.timeoutMS} ms per MongoDB operation`
876
+ ].filter(Boolean);
877
+ result.tools = result.tools.filter(
878
+ ({ name }) => hasWritableConnection || !WRITE_OPERATIONS.has(name)
758
879
  );
759
- if (!hasValidOperator) {
760
- throw new Error(
761
- "Update must include at least one valid update operator ($set, $unset, etc.)"
762
- );
880
+ const { tools } = result;
881
+ for (const tool of tools) {
882
+ if (WRITE_OPERATIONS.has(tool.name)) {
883
+ tool.annotations = { ...tool.annotations, readOnlyHint: false };
884
+ if (connectionSelection) {
885
+ tool.description += " Select a writable connection with listConnections.";
886
+ }
887
+ }
888
+ if (tool.name === "aggregate" && !hasWritableConnection) {
889
+ tool.annotations = { ...tool.annotations, readOnlyHint: true };
890
+ }
891
+ if ((tool.name === "query" || tool.name === "aggregate") && configuredLimits.length) {
892
+ tool.description += ` Configured limits include ${configuredLimits.join(", ")}.`;
893
+ }
763
894
  }
764
- try {
765
- const options = {
766
- upsert: !!upsert,
767
- multi: !!multi
895
+ if (connectionSelection) {
896
+ const connectionId = {
897
+ type: "string",
898
+ description: "Configured connection ID. Defaults to default."
768
899
  };
769
- const updateMethod = options.multi ? "updateMany" : "updateOne";
770
- signal?.throwIfAborted();
771
- const result = await collection[updateMethod](
772
- queryFilter,
773
- processedUpdate,
774
- options
775
- );
776
- return formatResponse({
777
- matchedCount: result.matchedCount,
778
- modifiedCount: result.modifiedCount,
779
- upsertedCount: result.upsertedCount,
780
- upsertedId: result.upsertedId
781
- });
782
- } catch (error) {
783
- return handleError(error, "update", collection.collectionName);
784
- }
785
- }
786
- async function handleServerInfo(db, isReadOnlyMode, args, signal) {
787
- const { includeDebugInfo } = args;
788
- try {
789
- signal?.throwIfAborted();
790
- const buildInfo = await db.command({ buildInfo: 1 });
791
- const serverInfo = {
792
- version: buildInfo.version,
793
- gitVersion: buildInfo.gitVersion,
794
- storageEngines: buildInfo.storageEngines,
795
- maxBsonObjectSize: buildInfo.maxBsonObjectSize,
796
- bits: buildInfo.bits,
797
- ok: buildInfo.ok,
798
- connectionInfo: {
799
- readOnlyMode: isReadOnlyMode
800
- }
900
+ const database = {
901
+ type: "string",
902
+ description: "Allowed database name. Defaults to the database in the connection URI."
801
903
  };
802
- if (includeDebugInfo) {
803
- signal?.throwIfAborted();
804
- const serverStatus = await db.command({ serverStatus: 1 });
805
- serverInfo.modules = buildInfo.modules;
806
- serverInfo.allocator = buildInfo.allocator;
807
- serverInfo.javascriptEngine = buildInfo.javascriptEngine;
808
- serverInfo.sysInfo = buildInfo.sysInfo;
809
- serverInfo.debug = buildInfo.debug;
810
- serverInfo.openssl = buildInfo.openssl;
811
- serverInfo.buildEnvironment = buildInfo.buildEnvironment;
812
- serverInfo.status = {
813
- host: serverStatus.host,
814
- version: serverStatus.version,
815
- process: serverStatus.process,
816
- pid: serverStatus.pid,
817
- uptime: serverStatus.uptime,
818
- uptimeMillis: serverStatus.uptimeMillis,
819
- uptimeEstimate: serverStatus.uptimeEstimate,
820
- localTime: serverStatus.localTime,
821
- connections: serverStatus.connections,
822
- network: serverStatus.network,
823
- memory: serverStatus.mem,
824
- storageEngine: serverStatus.storageEngine,
825
- security: serverStatus.security
826
- };
904
+ for (const tool of tools) {
905
+ if (tool.name !== "convertTime") {
906
+ tool.inputSchema.properties = {
907
+ ...tool.inputSchema.properties,
908
+ connectionId,
909
+ database
910
+ };
911
+ }
827
912
  }
828
- return formatResponse(serverInfo);
829
- } catch (error) {
830
- return handleError(error, "get server information");
913
+ tools.push(
914
+ {
915
+ name: "listConnections",
916
+ description: "List configured connection IDs, default databases, and read-only policies.",
917
+ inputSchema: { type: "object", properties: {} }
918
+ },
919
+ {
920
+ name: "listDatabases",
921
+ description: "List existing authorized databases on a configured connection.",
922
+ inputSchema: { type: "object", properties: { connectionId } }
923
+ }
924
+ );
831
925
  }
926
+ return result;
832
927
  }
833
- async function handleInsert(collection, args, objectIdMode = "auto", signal) {
834
- if (!collection) {
835
- throw new Error("Collection is required for insert operation");
836
- }
837
- const { documents, ordered, writeConcern, bypassDocumentValidation } = args;
838
- if (!Array.isArray(documents)) {
839
- throw new Error("Documents must be an array");
840
- }
841
- if (documents.length === 0) {
842
- throw new Error("Documents array cannot be empty");
928
+
929
+ // src/schemas/call.ts
930
+ var COLLECTION_OPERATIONS = [
931
+ "query",
932
+ "aggregate",
933
+ "update",
934
+ "insert",
935
+ "createIndex",
936
+ "count",
937
+ "listIndexes",
938
+ "collectionSchema"
939
+ ];
940
+ var MAX_OBJECT_DEPTH = 100;
941
+ var WRITE_AGG_STAGES = /* @__PURE__ */ new Set(["$out", "$merge"]);
942
+ var SERVER_JS_AGG_OPERATORS = /* @__PURE__ */ new Set([
943
+ "$function",
944
+ "$accumulator",
945
+ "$where"
946
+ ]);
947
+ var READONLY_FORBIDDEN_AGG_OPERATORS = /* @__PURE__ */ new Set([
948
+ ...WRITE_AGG_STAGES,
949
+ ...SERVER_JS_AGG_OPERATORS
950
+ ]);
951
+ function findAggOperator(value, operators, depth = 0) {
952
+ if (depth > MAX_OBJECT_DEPTH) {
953
+ throw new Error(
954
+ `Object nesting exceeds the maximum depth of ${MAX_OBJECT_DEPTH}`
955
+ );
843
956
  }
844
- if (!documents.every(
845
- (doc) => doc && typeof doc === "object" && !Array.isArray(doc)
846
- )) {
847
- throw new Error("Each document must be a valid MongoDB document object");
957
+ if (Array.isArray(value)) {
958
+ for (const item of value) {
959
+ const found = findAggOperator(item, operators, depth + 1);
960
+ if (found) return found;
961
+ }
962
+ return null;
848
963
  }
849
- const processedDocuments = documents.map(
850
- (doc) => processObjectIdInFilter(doc, objectIdMode)
851
- );
852
- try {
853
- const options = {
854
- ordered: ordered !== false,
855
- // default to true if not specified
856
- writeConcern,
857
- bypassDocumentValidation
858
- };
859
- signal?.throwIfAborted();
860
- const result = await collection.insertMany(
861
- processedDocuments,
862
- options
863
- );
864
- return formatResponse({
865
- acknowledged: result.acknowledged,
866
- insertedCount: result.insertedCount,
867
- insertedIds: result.insertedIds
868
- });
869
- } catch (error) {
870
- if (error instanceof Error && error.name === "BulkWriteError") {
871
- const bulkError = error;
872
- return formatResponse({
873
- error: "Bulk write error occurred",
874
- writeErrors: bulkError.writeErrors,
875
- insertedCount: bulkError.result?.nInserted || 0,
876
- failedCount: bulkError.result?.nFailedInserts || 0
877
- });
964
+ if (value && typeof value === "object") {
965
+ for (const [key, nested] of Object.entries(value)) {
966
+ if (operators.has(key)) {
967
+ return key;
968
+ }
969
+ const found = findAggOperator(nested, operators, depth + 1);
970
+ if (found) return found;
878
971
  }
879
- return handleError(error, "insert", collection.collectionName);
880
972
  }
973
+ return null;
881
974
  }
882
- async function handleCreateIndex(collection, args, objectIdMode = "auto", signal) {
883
- if (!collection) {
884
- throw new Error("Collection is required for createIndex operation");
885
- }
886
- const { indexes, commitQuorum, writeConcern } = args;
887
- if (!Array.isArray(indexes) || indexes.length === 0) {
888
- throw new Error("Indexes must be a non-empty array");
889
- }
890
- if (writeConcern && (typeof writeConcern !== "object" || Array.isArray(writeConcern))) {
975
+ function assertNoServerJs(value) {
976
+ const operator = findAggOperator(value, SERVER_JS_AGG_OPERATORS);
977
+ if (operator) {
891
978
  throw new Error(
892
- "Write concern must be a valid MongoDB write concern object"
979
+ `Server-side JavaScript operator '${operator}' is disabled by the current server policy.`
893
980
  );
894
981
  }
895
- if (commitQuorum !== void 0 && typeof commitQuorum !== "string" && typeof commitQuorum !== "number") {
896
- throw new Error("Commit quorum must be a string or number");
897
- }
898
- const processedIndexes = indexes.map((index) => {
899
- if (index && typeof index === "object") {
900
- return processObjectIdInFilter(
901
- index,
902
- objectIdMode
903
- );
904
- }
905
- return index;
906
- });
907
- try {
908
- const indexOptions = {
909
- commitQuorum
910
- };
911
- signal?.throwIfAborted();
912
- const createdIndexes = await collection.createIndexes(
913
- processedIndexes,
914
- indexOptions
915
- );
916
- return formatResponse({
917
- acknowledged: true,
918
- createdIndexes,
919
- indexCount: createdIndexes.length
920
- });
921
- } catch (error) {
922
- return handleError(error, "create indexes", collection.collectionName);
982
+ }
983
+ function findCrossDbTarget(pipeline, connectedDb) {
984
+ const foreignDb = (target) => {
985
+ if (target && typeof target === "object" && "db" in target) {
986
+ const db = target.db;
987
+ if (typeof db === "string" && db && db !== connectedDb) return db;
988
+ }
989
+ return null;
990
+ };
991
+ for (const stage of pipeline) {
992
+ if (!stage || typeof stage !== "object") continue;
993
+ const s = stage;
994
+ const out = foreignDb(s.$out);
995
+ if (out) return out;
996
+ const merge = s.$merge;
997
+ const mergeInto = foreignDb(merge?.into ?? s.$merge);
998
+ if (mergeInto) return mergeInto;
999
+ const lookup = s.$lookup;
1000
+ const lookupFrom = foreignDb(lookup?.from);
1001
+ if (lookupFrom) return lookupFrom;
923
1002
  }
1003
+ return null;
924
1004
  }
925
- async function handleCount(collection, args, objectIdMode = "auto", allowServerJs = false, signal) {
926
- if (!collection) {
927
- throw new Error("Collection is required for count operation");
1005
+ async function handleCallToolRequest({
1006
+ request,
1007
+ client,
1008
+ db,
1009
+ isReadOnlyMode,
1010
+ allowCrossDb = false,
1011
+ allowServerJs = false,
1012
+ connections: connections2,
1013
+ queryLimits = {},
1014
+ signal
1015
+ }) {
1016
+ const { name, arguments: args = {} } = request.params;
1017
+ const operation = name;
1018
+ const objectIdMode = args.objectIdMode || "auto";
1019
+ const filteredArgs = {};
1020
+ for (const [key, value] of Object.entries(args)) {
1021
+ if (key !== "objectIdMode") {
1022
+ filteredArgs[key] = value;
1023
+ }
928
1024
  }
929
- const { query, limit, skip, hint, readConcern, maxTimeMS, collation } = args;
930
- const countQuery = parseFilter(query, objectIdMode);
931
- if (!allowServerJs) {
932
- assertNoServerJs(countQuery);
1025
+ if (args.sort) {
1026
+ args.sort = parseSort(args.sort);
1027
+ }
1028
+ Object.assign(args, filteredArgs);
1029
+ if (!connections2 || name !== "listConnections" && name !== "listDatabases") {
1030
+ validateOperation(operation);
933
1031
  }
934
1032
  try {
935
- const options = {
936
- limit: typeof limit === "number" ? limit : void 0,
937
- skip: typeof skip === "number" ? skip : void 0,
938
- hint: typeof hint === "object" && hint !== null ? hint : void 0,
939
- readConcern: typeof readConcern === "object" && readConcern !== null ? readConcern : void 0,
940
- maxTimeMS: typeof maxTimeMS === "number" ? maxTimeMS : void 0,
941
- collation: typeof collation === "object" && collation !== null ? collation : void 0
942
- };
943
- for (const key of Object.keys(options)) {
944
- if (options[key] === void 0) {
945
- delete options[key];
1033
+ signal?.throwIfAborted();
1034
+ if (connections2 && name === "listConnections") {
1035
+ return formatResponse({ connections: connections2.list() });
1036
+ }
1037
+ if (connections2 && name === "listDatabases") {
1038
+ return formatResponse({
1039
+ databases: await connections2.listDatabases(args.connectionId)
1040
+ });
1041
+ }
1042
+ if (connections2 && operation !== "convertTime") {
1043
+ const target = await connections2.resolve(args);
1044
+ db = target.db;
1045
+ isReadOnlyMode = target.isReadOnlyMode;
1046
+ if (connections2.named && operation === "aggregate" && Array.isArray(args.pipeline)) {
1047
+ const allowed = connections2.allowedDatabases(target.connectionId);
1048
+ checkNamedAggregationDatabases(
1049
+ args.pipeline,
1050
+ db.databaseName,
1051
+ allowCrossDb ? allowed : [db.databaseName]
1052
+ );
1053
+ }
1054
+ }
1055
+ checkReadOnlyMode(operation, isReadOnlyMode);
1056
+ let collection = null;
1057
+ if (COLLECTION_OPERATIONS.includes(operation)) {
1058
+ const collectionName = args.collection;
1059
+ if (!collectionName) {
1060
+ throw new Error(
1061
+ `Collection name is required for '${operation}' operation`
1062
+ );
946
1063
  }
1064
+ collection = db.collection(collectionName);
1065
+ validateCollection(collection);
947
1066
  }
948
1067
  signal?.throwIfAborted();
949
- const count = await collection.countDocuments(countQuery, options);
950
- return formatResponse({
951
- count,
952
- ok: 1
953
- });
954
- } catch (error) {
955
- return handleError(error, "count documents", collection.collectionName);
956
- }
957
- }
958
- function handleConvertTime(args) {
959
- const { input } = args;
960
- let date;
961
- if (input === void 0 || input === null || input === "") {
962
- date = /* @__PURE__ */ new Date();
963
- } else if (typeof input === "number") {
964
- date = new Date(Math.abs(input) < 1e12 ? input * 1e3 : input);
965
- } else if (typeof input === "string") {
966
- const trimmed = input.trim();
967
- if (/^-?\d+$/.test(trimmed)) {
968
- const n = Number(trimmed);
969
- date = new Date(Math.abs(n) < 1e12 ? n * 1e3 : n);
970
- } else {
971
- date = new Date(trimmed);
1068
+ if (collection && (operation === "listIndexes" || operation === "collectionSchema")) {
1069
+ const target = `${db.databaseName}.${collection.collectionName}`;
1070
+ try {
1071
+ const options = { signal, timeoutMS: queryLimits.timeoutMS };
1072
+ return formatResponse(
1073
+ operation === "listIndexes" ? { indexes: await collection.indexes(options) } : await buildCollectionSchema(db, collection, signal)
1074
+ );
1075
+ } catch (error) {
1076
+ signal?.throwIfAborted();
1077
+ return handleError(error, operation, target);
1078
+ }
972
1079
  }
973
- } else {
974
- throw new Error(
975
- "convertTime 'input' must be a Unix timestamp (number) or a date string"
1080
+ return await executeOperation(
1081
+ operation,
1082
+ collection,
1083
+ db,
1084
+ isReadOnlyMode,
1085
+ allowCrossDb,
1086
+ allowServerJs,
1087
+ args,
1088
+ objectIdMode,
1089
+ queryLimits,
1090
+ signal
976
1091
  );
977
- }
978
- if (Number.isNaN(date.getTime())) {
979
- throw new Error(
980
- `Could not interpret '${String(input)}' as a date or Unix timestamp`
1092
+ } catch (error) {
1093
+ if (error instanceof ProtocolError3 || signal?.aborted) {
1094
+ throw error;
1095
+ }
1096
+ return toolExecutionError(
1097
+ error instanceof Error ? error.message : "Unknown error"
981
1098
  );
982
1099
  }
983
- const offsetMinutes = -date.getTimezoneOffset();
984
- const sign = offsetMinutes >= 0 ? "+" : "-";
985
- const absMinutes = Math.abs(offsetMinutes);
986
- const offsetLabel = `${sign}${String(Math.floor(absMinutes / 60)).padStart(
987
- 2,
988
- "0"
989
- )}:${String(absMinutes % 60).padStart(2, "0")}`;
990
- return formatResponse({
991
- iso: date.toISOString(),
992
- utc: date.toUTCString(),
993
- unixSeconds: Math.floor(date.getTime() / 1e3),
994
- unixMillis: date.getTime(),
995
- serverLocal: date.toString(),
996
- serverTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
997
- serverUtcOffset: offsetLabel
998
- });
999
1100
  }
1000
- async function handleListCollections(db, args, objectIdMode = "auto", signal) {
1001
- const { nameOnly, filter } = args;
1002
- const skip = args.skip || 0;
1003
- const limit = args.limit || 20;
1004
- try {
1005
- const processedFilter = parseFilter(filter, objectIdMode);
1006
- signal?.throwIfAborted();
1007
- const collections = await db.listCollections(processedFilter).toArray();
1008
- const allResults = nameOnly ? collections.map((collection) => collection.name) : collections;
1009
- const total = allResults.length;
1010
- const paged = allResults.slice(skip, skip + limit);
1011
- return formatResponse({
1012
- results: paged,
1013
- metadata: {
1014
- total,
1015
- returned: paged.length,
1016
- skip,
1017
- limit,
1018
- hasMore: skip + paged.length < total
1101
+ function checkNamedAggregationDatabases(pipeline, database, allowed, depth = 0) {
1102
+ if (depth > MAX_OBJECT_DEPTH)
1103
+ throw new Error("Aggregation pipeline nesting is too deep");
1104
+ for (const stage of pipeline) {
1105
+ if (!stage || typeof stage !== "object") continue;
1106
+ const { $out, $merge, $lookup, $unionWith, $facet } = stage;
1107
+ for (const target of [$out, $merge, $lookup, $unionWith]) {
1108
+ if (!target || typeof target !== "object") continue;
1109
+ const value = target;
1110
+ for (const reference of [value, value.into, value.from]) {
1111
+ if (!reference || typeof reference !== "object" || !("db" in reference))
1112
+ continue;
1113
+ if (typeof reference.db !== "string" || !allowed.includes(reference.db)) {
1114
+ throw new Error(
1115
+ `Aggregation references a database outside the allowed scope of '${database}'`
1116
+ );
1117
+ }
1019
1118
  }
1020
- });
1021
- } catch (error) {
1022
- return handleError(error, "list collections");
1119
+ if (Array.isArray(value.pipeline))
1120
+ checkNamedAggregationDatabases(
1121
+ value.pipeline,
1122
+ database,
1123
+ allowed,
1124
+ depth + 1
1125
+ );
1126
+ }
1127
+ if ($facet && typeof $facet === "object") {
1128
+ for (const nested of Object.values($facet)) {
1129
+ if (Array.isArray(nested))
1130
+ checkNamedAggregationDatabases(nested, database, allowed, depth + 1);
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ async function executeOperation(operation, collection, db, isReadOnlyMode, allowCrossDb, allowServerJs, args, objectIdMode, queryLimits, signal) {
1136
+ switch (operation) {
1137
+ case "query":
1138
+ return handleQuery(
1139
+ collection,
1140
+ args,
1141
+ objectIdMode,
1142
+ allowServerJs && !isReadOnlyMode,
1143
+ queryLimits,
1144
+ signal
1145
+ );
1146
+ case "aggregate":
1147
+ return handleAggregate(
1148
+ collection,
1149
+ args,
1150
+ objectIdMode,
1151
+ isReadOnlyMode,
1152
+ allowCrossDb,
1153
+ allowServerJs,
1154
+ queryLimits,
1155
+ signal
1156
+ );
1157
+ case "update":
1158
+ return handleUpdate(
1159
+ collection,
1160
+ args,
1161
+ objectIdMode,
1162
+ allowServerJs,
1163
+ signal
1164
+ );
1165
+ case "serverInfo":
1166
+ return handleServerInfo(db, isReadOnlyMode, args, signal);
1167
+ case "insert":
1168
+ return handleInsert(collection, args, objectIdMode, signal);
1169
+ case "createIndex":
1170
+ return handleCreateIndex(collection, args, objectIdMode, signal);
1171
+ case "count":
1172
+ return handleCount(
1173
+ collection,
1174
+ args,
1175
+ objectIdMode,
1176
+ allowServerJs && !isReadOnlyMode,
1177
+ signal
1178
+ );
1179
+ case "listCollections":
1180
+ return handleListCollections(db, args, objectIdMode, signal);
1181
+ case "convertTime":
1182
+ return handleConvertTime(args);
1183
+ default:
1184
+ throw new ProtocolError3(
1185
+ ProtocolErrorCode3.InvalidParams,
1186
+ `Unknown tool: ${operation}`
1187
+ );
1023
1188
  }
1024
1189
  }
1025
-
1026
- // src/utils/pagination.ts
1027
- var DEFAULT_PAGE_SIZE = 20;
1028
- function encodeCursor(offset) {
1029
- const data = { offset };
1030
- return Buffer.from(JSON.stringify(data)).toString("base64");
1031
- }
1032
- function decodeCursor(cursor) {
1033
- if (!cursor) return 0;
1034
- try {
1035
- const data = JSON.parse(
1036
- Buffer.from(cursor, "base64").toString("utf-8")
1190
+ function validateOperation(operation) {
1191
+ const validOperations = [
1192
+ "query",
1193
+ "aggregate",
1194
+ "update",
1195
+ "serverInfo",
1196
+ "insert",
1197
+ "createIndex",
1198
+ "count",
1199
+ "listCollections",
1200
+ "listIndexes",
1201
+ "collectionSchema",
1202
+ "convertTime"
1203
+ ];
1204
+ if (!validOperations.includes(operation)) {
1205
+ throw new ProtocolError3(
1206
+ ProtocolErrorCode3.InvalidParams,
1207
+ `Unknown tool: ${operation}`
1037
1208
  );
1038
- return typeof data.offset === "number" && data.offset >= 0 ? data.offset : 0;
1039
- } catch {
1040
- return 0;
1041
1209
  }
1042
1210
  }
1043
- function paginate(items, cursor, pageSize = DEFAULT_PAGE_SIZE) {
1044
- const offset = decodeCursor(cursor);
1045
- const page = items.slice(offset, offset + pageSize);
1046
- const nextOffset = offset + pageSize;
1047
- const nextCursor = nextOffset < items.length ? encodeCursor(nextOffset) : void 0;
1048
- return { items: page, nextCursor };
1049
- }
1050
-
1051
- // src/schemas/completion.ts
1052
- async function handleCompletionRequest({
1053
- request,
1054
- client,
1055
- db,
1056
- isReadOnlyMode,
1057
- signal
1058
- }) {
1059
- const { ref, argument } = request.params;
1060
- if (ref.type === "ref/prompt") {
1061
- return handlePromptCompletion(
1062
- client,
1063
- db,
1064
- isReadOnlyMode,
1065
- ref.name,
1066
- argument,
1067
- signal
1068
- );
1211
+ function validateCollection(collection) {
1212
+ if (!collection.collectionName) {
1213
+ throw new Error("Collection name cannot be empty");
1069
1214
  }
1070
- if (ref.type === "ref/resource") {
1071
- return handleResourceCompletion(
1072
- client,
1073
- db,
1074
- isReadOnlyMode,
1075
- ref.uri,
1076
- argument,
1077
- signal
1215
+ if (collection.collectionName.toLowerCase().startsWith("system.")) {
1216
+ throw new Error("Access to system collections is not allowed");
1217
+ }
1218
+ }
1219
+ function checkReadOnlyMode(operation, isReadOnlyMode) {
1220
+ if (isReadOnlyMode && WRITE_OPERATIONS.has(operation)) {
1221
+ throw new Error(
1222
+ `ReadonlyError: Operation '${operation}' is not allowed in read-only mode`
1078
1223
  );
1079
1224
  }
1080
- return emptyCompletionResult();
1081
1225
  }
1082
- async function handlePromptCompletion(client, db, isReadOnlyMode, promptName, argument, signal) {
1083
- if (!promptName) {
1084
- return emptyCompletionResult();
1226
+ function parseSort(sort) {
1227
+ if (!sort) return null;
1228
+ if (typeof sort !== "object" || sort === null || Array.isArray(sort)) {
1229
+ return null;
1085
1230
  }
1086
- if (argument.name === "collection") {
1087
- return await completeCollectionNames(
1088
- argument.value,
1089
- db,
1090
- isReadOnlyMode,
1091
- signal
1092
- );
1231
+ const validSort = {};
1232
+ for (const [key, value] of Object.entries(sort)) {
1233
+ if (typeof value === "number" && (value === 1 || value === -1)) {
1234
+ validSort[key] = value;
1235
+ }
1093
1236
  }
1094
- return emptyCompletionResult();
1237
+ return Object.keys(validSort).length > 0 ? validSort : null;
1095
1238
  }
1096
- async function handleResourceCompletion(client, db, isReadOnlyMode, promptName, argument, signal) {
1097
- if (!promptName) {
1098
- return emptyCompletionResult();
1239
+ function parseFilter(filter, objectIdMode = "auto") {
1240
+ if (!filter) {
1241
+ return {};
1099
1242
  }
1100
- if (argument.name === "collection") {
1101
- return await completeCollectionNames(
1102
- argument.value,
1103
- db,
1104
- isReadOnlyMode,
1105
- signal
1243
+ if (typeof filter === "string") {
1244
+ try {
1245
+ return processObjectIdInFilter(JSON.parse(filter), objectIdMode);
1246
+ } catch {
1247
+ throw new Error("Invalid filter format: must be a valid JSON object");
1248
+ }
1249
+ }
1250
+ if (typeof filter === "object" && filter !== null && !Array.isArray(filter)) {
1251
+ return processObjectIdInFilter(
1252
+ filter,
1253
+ objectIdMode
1106
1254
  );
1107
1255
  }
1108
- return emptyCompletionResult();
1256
+ throw new Error("Query filter must be a plain object or ObjectId");
1109
1257
  }
1110
- async function completeCollectionNames(partialValue, db, isReadOnlyMode, signal) {
1111
- try {
1112
- console.warn(
1113
- `Completing collection names with partial value: ${partialValue}`
1114
- );
1115
- signal?.throwIfAborted();
1116
- const collections = await db.listCollections().toArray();
1117
- const matchingCollections = collections.map(
1118
- (c) => c.name
1119
- ).filter(
1120
- (name) => (
1121
- // Filter out system collections
1122
- !name.startsWith("system.") && // Match partial value
1123
- name.toLowerCase().includes(partialValue.toLowerCase())
1124
- )
1125
- ).sort();
1126
- console.warn(`Found ${matchingCollections.length} matching collections`);
1127
- const PAGE_SIZE = 100;
1128
- const { items: limitedResults, nextCursor } = paginate(
1129
- matchingCollections,
1130
- void 0,
1131
- PAGE_SIZE
1258
+ function isObjectIdField(fieldName) {
1259
+ const lowerFieldName = fieldName.toLowerCase();
1260
+ return lowerFieldName === "_id" || lowerFieldName === "id" || lowerFieldName.endsWith("id") || lowerFieldName.endsWith("_id");
1261
+ }
1262
+ function processObjectIdInFilter(filter, objectIdMode = "auto", depth = 0) {
1263
+ if (depth > MAX_OBJECT_DEPTH) {
1264
+ throw new Error(
1265
+ `Object nesting exceeds the maximum depth of ${MAX_OBJECT_DEPTH}`
1132
1266
  );
1133
- return {
1134
- completion: {
1135
- values: limitedResults,
1136
- total: matchingCollections.length,
1137
- hasMore: !!nextCursor
1267
+ }
1268
+ if (objectIdMode === "none") {
1269
+ const result2 = {};
1270
+ for (const [key, value] of Object.entries(filter)) {
1271
+ if (typeof value === "string" && isISODateString(value)) {
1272
+ result2[key] = new Date(value);
1273
+ } else if (typeof value === "string" && value.startsWith("ISODate(") && value.endsWith(")")) {
1274
+ const dateString = value.substring(8, value.length - 2);
1275
+ if (isISODateString(dateString)) {
1276
+ result2[key] = new Date(dateString);
1277
+ } else {
1278
+ result2[key] = value;
1279
+ }
1280
+ } else if (typeof value === "object" && value !== null) {
1281
+ if (Array.isArray(value)) {
1282
+ result2[key] = value.map((item) => {
1283
+ if (typeof item === "string" && isISODateString(item)) {
1284
+ return new Date(item);
1285
+ } else if (typeof item === "string" && item.startsWith("ISODate(") && item.endsWith(")")) {
1286
+ const dateString = item.substring(8, item.length - 2);
1287
+ return isISODateString(dateString) ? new Date(dateString) : item;
1288
+ }
1289
+ return item;
1290
+ });
1291
+ } else {
1292
+ result2[key] = processObjectIdInFilter(
1293
+ value,
1294
+ "none",
1295
+ depth + 1
1296
+ );
1297
+ }
1298
+ } else {
1299
+ result2[key] = value;
1300
+ }
1301
+ }
1302
+ return result2;
1303
+ }
1304
+ const result = {};
1305
+ for (const [key, value] of Object.entries(filter)) {
1306
+ if (typeof value === "string" && isObjectIdString(value)) {
1307
+ if (objectIdMode === "force" || objectIdMode === "auto" && isObjectIdField(key)) {
1308
+ result[key] = new ObjectId2(value);
1309
+ } else {
1310
+ result[key] = value;
1311
+ }
1312
+ } else if (typeof value === "string" && isISODateString(value)) {
1313
+ result[key] = new Date(value);
1314
+ } else if (typeof value === "string" && value.startsWith("ISODate(") && value.endsWith(")")) {
1315
+ const dateString = value.substring(8, value.length - 2);
1316
+ if (isISODateString(dateString)) {
1317
+ result[key] = new Date(dateString);
1318
+ } else {
1319
+ result[key] = value;
1320
+ }
1321
+ } else if (typeof value === "object" && value !== null) {
1322
+ if (Array.isArray(value)) {
1323
+ result[key] = value.map((item) => {
1324
+ if (typeof item === "string" && isObjectIdString(item) && (objectIdMode === "force" || objectIdMode === "auto" && isObjectIdField(key))) {
1325
+ return new ObjectId2(item);
1326
+ } else if (typeof item === "string" && isISODateString(item)) {
1327
+ return new Date(item);
1328
+ } else if (typeof item === "string" && item.startsWith("ISODate(") && item.endsWith(")")) {
1329
+ const dateString = item.substring(8, item.length - 2);
1330
+ return isISODateString(dateString) ? new Date(dateString) : item;
1331
+ }
1332
+ return item;
1333
+ });
1334
+ } else {
1335
+ result[key] = processObjectIdInFilter(
1336
+ value,
1337
+ objectIdMode,
1338
+ depth + 1
1339
+ );
1340
+ }
1341
+ } else {
1342
+ result[key] = value;
1343
+ }
1344
+ }
1345
+ return result;
1346
+ }
1347
+ function isObjectIdString(str) {
1348
+ return /^[0-9a-fA-F]{24}$/.test(str);
1349
+ }
1350
+ function isISODateString(str) {
1351
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/.test(
1352
+ str
1353
+ );
1354
+ }
1355
+ function formatResponse(data) {
1356
+ return {
1357
+ content: [
1358
+ {
1359
+ type: "text",
1360
+ text: JSON.stringify(data, null, 2)
1138
1361
  }
1139
- };
1140
- } catch (error) {
1141
- console.error("Error completing collection names:", error);
1142
- return emptyCompletionResult();
1143
- }
1362
+ ]
1363
+ };
1144
1364
  }
1145
- function emptyCompletionResult() {
1365
+ function toolExecutionError(message) {
1146
1366
  return {
1147
- completion: {
1148
- values: [],
1149
- total: 0,
1150
- hasMore: false
1151
- }
1367
+ content: [{ type: "text", text: message }],
1368
+ isError: true
1152
1369
  };
1153
1370
  }
1154
-
1155
- // src/schemas/ping.ts
1156
- async function handlePingRequest({
1157
- request,
1158
- client,
1159
- db,
1160
- isReadOnlyMode,
1161
- signal
1162
- }) {
1371
+ function handleError(error, operation, collectionName) {
1372
+ if (error instanceof Error && error.name === "AbortError") {
1373
+ throw error;
1374
+ }
1375
+ const context = collectionName ? `collection ${collectionName}` : "operation";
1376
+ const message = error instanceof Error ? error.message : "Unknown error";
1377
+ return toolExecutionError(`Failed to ${operation} ${context}: ${message}`);
1378
+ }
1379
+ async function handleQuery(collection, args, objectIdMode = "auto", allowServerJs = false, limits = {}, signal) {
1380
+ if (!collection) {
1381
+ throw new Error("Collection is required for query operation");
1382
+ }
1383
+ const { filter, projection, explain, sort, includeTotal = true } = args;
1384
+ if (typeof includeTotal !== "boolean")
1385
+ throw new Error("includeTotal must be a boolean");
1386
+ const limit = args.limit || 10;
1387
+ const skip = args.skip || 0;
1388
+ const queryFilter = parseFilter(filter, objectIdMode);
1389
+ if (!allowServerJs) {
1390
+ assertNoServerJs(queryFilter);
1391
+ }
1163
1392
  try {
1164
- if (!client) {
1165
- throw new Error("MongoDB connection is not available");
1393
+ if (explain) {
1394
+ signal?.throwIfAborted();
1395
+ const explainResult = await collection.find(queryFilter, {
1396
+ projection,
1397
+ limit,
1398
+ skip,
1399
+ sort,
1400
+ signal,
1401
+ timeoutMS: limits.timeoutMS
1402
+ }).explain(explain);
1403
+ return formatResponse(explainResult);
1166
1404
  }
1167
1405
  signal?.throwIfAborted();
1168
- const pong = await db.command({ ping: 1 });
1169
- if (pong.ok !== 1) {
1170
- throw new Error(`MongoDB ping failed: ${pong.errmsg}`);
1406
+ const bounded = limits.documents !== void 0 || limits.bytes !== void 0 || !includeTotal;
1407
+ const pageSize = Math.abs(limit);
1408
+ if (bounded && !Number.isSafeInteger(limit)) {
1409
+ throw new Error("limit must be an integer");
1171
1410
  }
1172
- return {};
1411
+ const pageRows = Math.min(pageSize, limits.documents ?? Infinity);
1412
+ const fetchLimit = bounded ? Math.sign(limit) * Math.min(pageRows + 1, Number.MAX_SAFE_INTEGER) : limit;
1413
+ const cancelPeer = new AbortController();
1414
+ const operationSignal = signal ? AbortSignal.any([signal, cancelPeer.signal]) : cancelPeer.signal;
1415
+ const options = { signal: operationSignal, timeoutMS: limits.timeoutMS };
1416
+ const cursor = collection.find(queryFilter, {
1417
+ projection,
1418
+ limit: fetchLimit,
1419
+ skip,
1420
+ sort,
1421
+ ...options
1422
+ });
1423
+ const read = bounded ? readResultPage(cursor, limits, pageSize, operationSignal) : cursor.toArray().then((results) => ({
1424
+ results,
1425
+ hasMore: false,
1426
+ truncated: void 0
1427
+ }));
1428
+ const count = includeTotal ? collection.countDocuments(queryFilter, options) : void 0;
1429
+ const [total, page] = await Promise.all([count, read]).catch(
1430
+ async (error) => {
1431
+ cancelPeer.abort(error);
1432
+ await Promise.allSettled([count, read]);
1433
+ throw error;
1434
+ }
1435
+ );
1436
+ return formatResponse({
1437
+ results: page.results,
1438
+ metadata: {
1439
+ ...total !== void 0 ? { total } : {},
1440
+ returned: page.results.length,
1441
+ skip,
1442
+ limit,
1443
+ hasMore: total === void 0 ? page.hasMore : page.hasMore || skip + page.results.length < total,
1444
+ ...page.truncated ? { truncated: page.truncated } : {}
1445
+ }
1446
+ });
1173
1447
  } catch (error) {
1174
- if (error instanceof Error) {
1175
- throw new Error(`MongoDB ping failed: ${error.message}`);
1176
- }
1177
- throw new Error("MongoDB ping failed: Unknown error");
1448
+ signal?.throwIfAborted();
1449
+ return handleError(error, "query", collection.collectionName);
1178
1450
  }
1179
1451
  }
1180
-
1181
- // src/schemas/prompts.ts
1182
- async function handleListPromptsRequest({
1183
- request,
1184
- client,
1185
- db,
1186
- isReadOnlyMode,
1187
- signal,
1188
- connectionSelection = false
1189
- }) {
1190
- return {
1191
- prompts: [
1192
- {
1193
- name: "analyze_collection",
1194
- description: "Analyze a MongoDB collection structure and contents",
1195
- arguments: [
1196
- {
1197
- name: "collection",
1198
- description: "Name of the collection to analyze",
1199
- required: true
1200
- },
1201
- ...connectionSelection ? [
1202
- {
1203
- name: "connectionId",
1204
- description: "Configured MongoDB connection ID"
1205
- },
1206
- {
1207
- name: "database",
1208
- description: "Allowed MongoDB database name"
1209
- }
1210
- ] : []
1211
- ]
1212
- }
1213
- ]
1214
- };
1215
- }
1216
- async function handleGetPromptRequest({
1217
- request,
1218
- client,
1219
- db,
1220
- isReadOnlyMode,
1221
- signal,
1222
- sendProgress,
1223
- connectionId,
1224
- showTarget = false
1225
- }) {
1226
- const { name, arguments: args = {} } = request.params;
1227
- if (name !== "analyze_collection") {
1228
- throw new Error("Unknown prompt");
1452
+ async function handleAggregate(collection, args, objectIdMode = "auto", isReadOnlyMode = false, allowCrossDb = false, allowServerJs = false, limits = {}, signal) {
1453
+ if (!collection) {
1454
+ throw new Error("Collection is required for aggregate operation");
1229
1455
  }
1230
- const collectionName = args.collection;
1231
- if (!collectionName) {
1232
- throw new Error("Collection name is required");
1456
+ const { pipeline, explain } = args;
1457
+ if (!Array.isArray(pipeline)) {
1458
+ throw new Error("Pipeline must be an array");
1459
+ }
1460
+ if (isReadOnlyMode) {
1461
+ const forbidden = findAggOperator(
1462
+ pipeline,
1463
+ READONLY_FORBIDDEN_AGG_OPERATORS
1464
+ );
1465
+ if (forbidden) {
1466
+ throw new Error(
1467
+ `ReadonlyError: Aggregation operator '${forbidden}' is not allowed in read-only mode`
1468
+ );
1469
+ }
1470
+ }
1471
+ if (!allowServerJs) {
1472
+ assertNoServerJs(pipeline);
1473
+ }
1474
+ if (!allowCrossDb) {
1475
+ const foreignDb = findCrossDbTarget(pipeline, collection.dbName);
1476
+ if (foreignDb) {
1477
+ throw new Error(
1478
+ `Cross-database access to '${foreignDb}' is not allowed (connected database is '${collection.dbName}'). Enable it with --allow-cross-db or MCP_MONGODB_ALLOW_CROSS_DB=true.`
1479
+ );
1480
+ }
1233
1481
  }
1482
+ const processedPipeline = pipeline.map((stage) => {
1483
+ if (typeof stage === "object" && stage !== null) {
1484
+ return processObjectIdInFilter(
1485
+ stage,
1486
+ objectIdMode
1487
+ );
1488
+ }
1489
+ return stage;
1490
+ });
1234
1491
  try {
1235
- const collection = db.collection(collectionName);
1236
- if (collection.collectionName.startsWith("system.")) {
1237
- throw new Error("Access to system collections is not allowed");
1492
+ if (explain) {
1493
+ signal?.throwIfAborted();
1494
+ const explainResult = await collection.aggregate(processedPipeline, {
1495
+ explain: {
1496
+ verbosity: explain
1497
+ },
1498
+ signal,
1499
+ timeoutMS: limits.timeoutMS
1500
+ }).toArray();
1501
+ return formatResponse(explainResult);
1238
1502
  }
1239
- await sendProgress?.(1, 3, "Getting schema");
1240
- signal?.throwIfAborted();
1241
- const schemaSample = await collection.findOne({});
1242
- await sendProgress?.(2, 3, "Getting stats");
1243
1503
  signal?.throwIfAborted();
1244
- const stats = await collection.aggregate([{ $collStats: { count: {} } }]).toArray();
1245
- await sendProgress?.(3, 3, "Fetching samples");
1504
+ const cursor = collection.aggregate(processedPipeline, {
1505
+ signal,
1506
+ timeoutMS: limits.timeoutMS
1507
+ });
1508
+ const { results, truncated } = limits.documents !== void 0 || limits.bytes !== void 0 ? await readResultPage(cursor, limits, Infinity, signal) : { results: await cursor.toArray(), truncated: void 0 };
1509
+ return formatResponse({
1510
+ results,
1511
+ metadata: {
1512
+ returned: results.length,
1513
+ ...truncated ? { truncated } : {}
1514
+ }
1515
+ });
1516
+ } catch (error) {
1246
1517
  signal?.throwIfAborted();
1247
- const sampleDocs = await collection.find({}).limit(5).toArray();
1248
- const documentCount = stats[0]?.count ?? "unknown";
1249
- return {
1250
- messages: [
1251
- {
1252
- role: "user",
1253
- content: {
1254
- type: "text",
1255
- text: `Please analyze the following MongoDB collection:
1256
- Collection: ${collectionName}${showTarget ? `
1257
- Connection: ${connectionId}
1258
- Database: ${db.databaseName}` : ""}
1259
-
1260
- Schema:
1261
- ${JSON.stringify(schemaSample, null, 2)}
1262
-
1263
- Stats:
1264
- Document count: ${documentCount}
1265
-
1266
- Sample documents:
1267
- ${JSON.stringify(sampleDocs, null, 2)}`
1268
- }
1269
- },
1270
- {
1271
- role: "user",
1272
- content: {
1273
- type: "text",
1274
- text: "Provide insights about the collection's structure, data types, and basic statistics."
1275
- }
1276
- }
1277
- ]
1518
+ return handleError(error, "aggregate", collection.collectionName);
1519
+ }
1520
+ }
1521
+ async function handleUpdate(collection, args, objectIdMode = "auto", allowServerJs = false, signal) {
1522
+ if (!collection) {
1523
+ throw new Error("Collection is required for update operation");
1524
+ }
1525
+ const { filter, update, upsert, multi } = args;
1526
+ const queryFilter = parseFilter(filter, objectIdMode);
1527
+ if (!allowServerJs) {
1528
+ assertNoServerJs(queryFilter);
1529
+ }
1530
+ let processedUpdate = update;
1531
+ if (update && typeof update === "object" && !Array.isArray(update)) {
1532
+ processedUpdate = processObjectIdInFilter(
1533
+ update,
1534
+ objectIdMode
1535
+ );
1536
+ }
1537
+ if (!processedUpdate || typeof processedUpdate !== "object" || Array.isArray(processedUpdate)) {
1538
+ throw new Error("Update must be a valid MongoDB update document");
1539
+ }
1540
+ const validUpdateOperators = [
1541
+ "$set",
1542
+ "$unset",
1543
+ "$inc",
1544
+ "$push",
1545
+ "$pull",
1546
+ "$addToSet",
1547
+ "$pop",
1548
+ "$rename",
1549
+ "$mul"
1550
+ ];
1551
+ const hasValidOperator = Object.keys(processedUpdate).some(
1552
+ (key) => validUpdateOperators.includes(key)
1553
+ );
1554
+ if (!hasValidOperator) {
1555
+ throw new Error(
1556
+ "Update must include at least one valid update operator ($set, $unset, etc.)"
1557
+ );
1558
+ }
1559
+ try {
1560
+ const options = {
1561
+ upsert: !!upsert,
1562
+ multi: !!multi
1278
1563
  };
1564
+ const updateMethod = options.multi ? "updateMany" : "updateOne";
1565
+ signal?.throwIfAborted();
1566
+ const result = await collection[updateMethod](
1567
+ queryFilter,
1568
+ processedUpdate,
1569
+ options
1570
+ );
1571
+ return formatResponse({
1572
+ matchedCount: result.matchedCount,
1573
+ modifiedCount: result.modifiedCount,
1574
+ upsertedCount: result.upsertedCount,
1575
+ upsertedId: result.upsertedId
1576
+ });
1279
1577
  } catch (error) {
1280
- const msg = error instanceof Error ? error.message : "Unknown error";
1281
- throw new Error(`Failed to analyze collection ${collectionName}: ${msg}`);
1578
+ return handleError(error, "update", collection.collectionName);
1282
1579
  }
1283
1580
  }
1284
-
1285
- // src/schemas/resource.ts
1286
- import {
1287
- ProtocolError as ProtocolError3,
1288
- ProtocolErrorCode as ProtocolErrorCode3
1289
- } from "@modelcontextprotocol/server";
1290
- import { ObjectId as ObjectId2 } from "mongodb";
1291
- function detectMongoType(value) {
1292
- if (value === null) return "null";
1293
- if (value === void 0) return "undefined";
1294
- if (value instanceof ObjectId2) return "ObjectId";
1295
- if (value instanceof Date) return "Date";
1296
- if (Array.isArray(value)) {
1297
- if (value.length === 0) return "Array";
1298
- const elementTypes = new Set(value.map((item) => detectMongoType(item)));
1299
- if (elementTypes.size === 1) {
1300
- return `Array<${Array.from(elementTypes)[0]}>`;
1581
+ async function handleServerInfo(db, isReadOnlyMode, args, signal) {
1582
+ const { includeDebugInfo } = args;
1583
+ try {
1584
+ signal?.throwIfAborted();
1585
+ const buildInfo = await db.command({ buildInfo: 1 });
1586
+ const serverInfo = {
1587
+ version: buildInfo.version,
1588
+ gitVersion: buildInfo.gitVersion,
1589
+ storageEngines: buildInfo.storageEngines,
1590
+ maxBsonObjectSize: buildInfo.maxBsonObjectSize,
1591
+ bits: buildInfo.bits,
1592
+ ok: buildInfo.ok,
1593
+ connectionInfo: {
1594
+ readOnlyMode: isReadOnlyMode
1595
+ }
1596
+ };
1597
+ if (includeDebugInfo) {
1598
+ signal?.throwIfAborted();
1599
+ const serverStatus = await db.command({ serverStatus: 1 });
1600
+ serverInfo.modules = buildInfo.modules;
1601
+ serverInfo.allocator = buildInfo.allocator;
1602
+ serverInfo.javascriptEngine = buildInfo.javascriptEngine;
1603
+ serverInfo.sysInfo = buildInfo.sysInfo;
1604
+ serverInfo.debug = buildInfo.debug;
1605
+ serverInfo.openssl = buildInfo.openssl;
1606
+ serverInfo.buildEnvironment = buildInfo.buildEnvironment;
1607
+ serverInfo.status = {
1608
+ host: serverStatus.host,
1609
+ version: serverStatus.version,
1610
+ process: serverStatus.process,
1611
+ pid: serverStatus.pid,
1612
+ uptime: serverStatus.uptime,
1613
+ uptimeMillis: serverStatus.uptimeMillis,
1614
+ uptimeEstimate: serverStatus.uptimeEstimate,
1615
+ localTime: serverStatus.localTime,
1616
+ connections: serverStatus.connections,
1617
+ network: serverStatus.network,
1618
+ memory: serverStatus.mem,
1619
+ storageEngine: serverStatus.storageEngine,
1620
+ security: serverStatus.security
1621
+ };
1301
1622
  }
1302
- return "Array<mixed>";
1303
- }
1304
- if (typeof value === "object") {
1305
- return "Document";
1623
+ return formatResponse(serverInfo);
1624
+ } catch (error) {
1625
+ return handleError(error, "get server information");
1306
1626
  }
1307
- return typeof value;
1308
1627
  }
1309
- function inferSchemaFromSamples(documents) {
1310
- if (!documents || documents.length === 0) {
1311
- return { fields: [] };
1628
+ async function handleInsert(collection, args, objectIdMode = "auto", signal) {
1629
+ if (!collection) {
1630
+ throw new Error("Collection is required for insert operation");
1312
1631
  }
1313
- const fieldMap = /* @__PURE__ */ new Map();
1314
- for (const doc of documents) {
1315
- for (const [key, value] of Object.entries(doc)) {
1316
- if (!fieldMap.has(key)) {
1317
- fieldMap.set(key, {
1318
- name: key,
1319
- types: /* @__PURE__ */ new Set([detectMongoType(value)]),
1320
- nullable: false,
1321
- // Store sample values for complex types
1322
- samples: [value]
1323
- });
1324
- } else {
1325
- const fieldInfo = fieldMap.get(key);
1326
- fieldInfo.types.add(detectMongoType(value));
1327
- if (fieldInfo.samples.length < 3 && !fieldInfo.samples.some(
1328
- (sample) => JSON.stringify(sample) === JSON.stringify(value)
1329
- )) {
1330
- fieldInfo.samples.push(value);
1331
- }
1332
- }
1333
- }
1632
+ const { documents, ordered, writeConcern, bypassDocumentValidation } = args;
1633
+ if (!Array.isArray(documents)) {
1634
+ throw new Error("Documents must be an array");
1334
1635
  }
1335
- for (const doc of documents) {
1336
- for (const [key] of fieldMap.entries()) {
1337
- if (!(key in doc)) {
1338
- const fieldInfo = fieldMap.get(key);
1339
- fieldInfo.nullable = true;
1340
- }
1341
- }
1636
+ if (documents.length === 0) {
1637
+ throw new Error("Documents array cannot be empty");
1342
1638
  }
1343
- for (const [key, fieldInfo] of fieldMap.entries()) {
1344
- if (fieldInfo.types.has("Document")) {
1345
- const nestedDocs = documents.filter(
1346
- (doc) => doc[key] && typeof doc[key] === "object" && !Array.isArray(doc[key])
1347
- ).map((doc) => doc[key]);
1348
- if (nestedDocs.length > 0) {
1349
- fieldInfo.nestedSchema = inferSchemaFromSamples(nestedDocs);
1350
- }
1351
- }
1639
+ if (!documents.every(
1640
+ (doc) => doc && typeof doc === "object" && !Array.isArray(doc)
1641
+ )) {
1642
+ throw new Error("Each document must be a valid MongoDB document object");
1352
1643
  }
1353
- const fields = Array.from(fieldMap.values()).map((fieldInfo) => {
1354
- const result = {
1355
- name: fieldInfo.name,
1356
- types: Array.from(fieldInfo.types),
1357
- nullable: fieldInfo.nullable,
1358
- prevalence: `${Math.round(
1359
- documents.filter((doc) => fieldInfo.name in doc).length / documents.length * 100
1360
- )}%`,
1361
- examples: []
1644
+ const processedDocuments = documents.map(
1645
+ (doc) => processObjectIdInFilter(doc, objectIdMode)
1646
+ );
1647
+ try {
1648
+ const options = {
1649
+ ordered: ordered !== false,
1650
+ // default to true if not specified
1651
+ writeConcern,
1652
+ bypassDocumentValidation
1362
1653
  };
1363
- if (fieldInfo.nestedSchema) {
1364
- result.nestedSchema = fieldInfo.nestedSchema;
1365
- }
1366
- const sampleValues = fieldInfo.samples.map((sample) => {
1367
- if (sample instanceof ObjectId2) return sample.toString();
1368
- if (sample instanceof Date) return sample.toISOString();
1369
- if (typeof sample === "object") {
1370
- return Array.isArray(sample) ? "[...]" : "{...}";
1371
- }
1372
- return sample;
1654
+ signal?.throwIfAborted();
1655
+ const result = await collection.insertMany(
1656
+ processedDocuments,
1657
+ options
1658
+ );
1659
+ return formatResponse({
1660
+ acknowledged: result.acknowledged,
1661
+ insertedCount: result.insertedCount,
1662
+ insertedIds: result.insertedIds
1373
1663
  });
1374
- result.examples = sampleValues;
1375
- return result;
1376
- });
1377
- return { fields };
1664
+ } catch (error) {
1665
+ if (error instanceof Error && error.name === "BulkWriteError") {
1666
+ const bulkError = error;
1667
+ return formatResponse({
1668
+ error: "Bulk write error occurred",
1669
+ writeErrors: bulkError.writeErrors,
1670
+ insertedCount: bulkError.result?.nInserted || 0,
1671
+ failedCount: bulkError.result?.nFailedInserts || 0
1672
+ });
1673
+ }
1674
+ return handleError(error, "insert", collection.collectionName);
1675
+ }
1378
1676
  }
1379
- async function handleReadResourceRequest({
1380
- request,
1381
- client,
1382
- db,
1383
- isReadOnlyMode,
1384
- signal,
1385
- sendProgress
1386
- }) {
1387
- let collectionName;
1388
- try {
1389
- const url = new URL(request.params.uri);
1390
- if (url.protocol !== "mongodb:") {
1391
- throw new ProtocolError3(
1392
- ProtocolErrorCode3.InvalidParams,
1393
- `Unsupported resource URI scheme '${url.protocol}': expected 'mongodb:'`
1677
+ async function handleCreateIndex(collection, args, objectIdMode = "auto", signal) {
1678
+ if (!collection) {
1679
+ throw new Error("Collection is required for createIndex operation");
1680
+ }
1681
+ const { indexes, commitQuorum, writeConcern } = args;
1682
+ if (!Array.isArray(indexes) || indexes.length === 0) {
1683
+ throw new Error("Indexes must be a non-empty array");
1684
+ }
1685
+ if (writeConcern && (typeof writeConcern !== "object" || Array.isArray(writeConcern))) {
1686
+ throw new Error(
1687
+ "Write concern must be a valid MongoDB write concern object"
1688
+ );
1689
+ }
1690
+ if (commitQuorum !== void 0 && typeof commitQuorum !== "string" && typeof commitQuorum !== "number") {
1691
+ throw new Error("Commit quorum must be a string or number");
1692
+ }
1693
+ const processedIndexes = indexes.map((index) => {
1694
+ if (index && typeof index === "object") {
1695
+ return processObjectIdInFilter(
1696
+ index,
1697
+ objectIdMode
1394
1698
  );
1395
1699
  }
1396
- collectionName = decodeURIComponent(url.pathname.replace(/^\//, ""));
1397
- } catch (error) {
1398
- if (error instanceof ProtocolError3) throw error;
1399
- throw new ProtocolError3(
1400
- ProtocolErrorCode3.InvalidParams,
1401
- `Invalid resource URI: ${request.params.uri}`
1700
+ return index;
1701
+ });
1702
+ try {
1703
+ const indexOptions = {
1704
+ commitQuorum
1705
+ };
1706
+ signal?.throwIfAborted();
1707
+ const createdIndexes = await collection.createIndexes(
1708
+ processedIndexes,
1709
+ indexOptions
1402
1710
  );
1711
+ return formatResponse({
1712
+ acknowledged: true,
1713
+ createdIndexes,
1714
+ indexCount: createdIndexes.length
1715
+ });
1716
+ } catch (error) {
1717
+ return handleError(error, "create indexes", collection.collectionName);
1403
1718
  }
1404
- if (!collectionName) {
1405
- throw new ProtocolError3(
1406
- ProtocolErrorCode3.InvalidParams,
1407
- "Resource URI is missing a collection name"
1408
- );
1719
+ }
1720
+ async function handleCount(collection, args, objectIdMode = "auto", allowServerJs = false, signal) {
1721
+ if (!collection) {
1722
+ throw new Error("Collection is required for count operation");
1409
1723
  }
1410
- if (collectionName.startsWith("system.")) {
1411
- throw new ProtocolError3(
1412
- ProtocolErrorCode3.InvalidParams,
1413
- "Access to system collections is not allowed"
1414
- );
1724
+ const { query, limit, skip, hint, readConcern, maxTimeMS, collation } = args;
1725
+ const countQuery = parseFilter(query, objectIdMode);
1726
+ if (!allowServerJs) {
1727
+ assertNoServerJs(countQuery);
1415
1728
  }
1416
1729
  try {
1417
- const collection = db.collection(collectionName);
1418
- const sampleSize = 100;
1419
- let sampleDocuments = [];
1420
- await sendProgress?.(1, 4, "Sampling documents");
1421
- try {
1422
- signal?.throwIfAborted();
1423
- sampleDocuments = await collection.aggregate([{ $sample: { size: sampleSize } }]).toArray();
1424
- } catch (sampleError) {
1425
- console.warn(
1426
- `$sample aggregation failed for ${collectionName}, falling back to sequential scan: ${sampleError}`
1427
- );
1428
- signal?.throwIfAborted();
1429
- sampleDocuments = await collection.find({}).limit(sampleSize).toArray();
1430
- }
1431
- await sendProgress?.(2, 4, "Getting indexes");
1432
- signal?.throwIfAborted();
1433
- const indexes = await collection.indexes();
1434
- const inferredSchema = inferSchemaFromSamples(sampleDocuments);
1435
- await sendProgress?.(3, 4, "Counting documents");
1436
- let documentCount = null;
1437
- try {
1438
- signal?.throwIfAborted();
1439
- documentCount = await Promise.race([
1440
- collection.countDocuments(),
1441
- new Promise(
1442
- (_, reject) => setTimeout(
1443
- () => reject(new Error("Count operation timed out")),
1444
- 5e3
1445
- )
1446
- )
1447
- ]);
1448
- } catch (countError) {
1449
- console.warn(
1450
- `Count operation failed or timed out for ${collectionName}: ${countError}`
1451
- );
1452
- try {
1453
- signal?.throwIfAborted();
1454
- const stats = await db.command({ collStats: collectionName });
1455
- documentCount = stats.count;
1456
- } catch {
1457
- documentCount = "unknown (count operation timed out)";
1730
+ const options = {
1731
+ limit: typeof limit === "number" ? limit : void 0,
1732
+ skip: typeof skip === "number" ? skip : void 0,
1733
+ hint: typeof hint === "object" && hint !== null ? hint : void 0,
1734
+ readConcern: typeof readConcern === "object" && readConcern !== null ? readConcern : void 0,
1735
+ maxTimeMS: typeof maxTimeMS === "number" ? maxTimeMS : void 0,
1736
+ collation: typeof collation === "object" && collation !== null ? collation : void 0
1737
+ };
1738
+ for (const key of Object.keys(options)) {
1739
+ if (options[key] === void 0) {
1740
+ delete options[key];
1458
1741
  }
1459
1742
  }
1460
- await sendProgress?.(4, 4, "Done");
1461
- const schema = {
1462
- type: "collection",
1463
- name: collectionName,
1464
- fields: inferredSchema.fields,
1465
- indexes: indexes.map((idx) => ({
1466
- name: idx.name,
1467
- keys: idx.key
1468
- })),
1469
- documentCount,
1470
- sampleSize: sampleDocuments.length,
1471
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
1472
- };
1473
- return {
1474
- contents: [
1475
- {
1476
- uri: request.params.uri,
1477
- mimeType: "application/json",
1478
- text: JSON.stringify(schema, null, 2)
1479
- }
1480
- ]
1481
- };
1743
+ signal?.throwIfAborted();
1744
+ const count = await collection.countDocuments(countQuery, options);
1745
+ return formatResponse({
1746
+ count,
1747
+ ok: 1
1748
+ });
1482
1749
  } catch (error) {
1483
- if (error instanceof Error) {
1484
- throw new Error(
1485
- `Failed to read collection ${collectionName}: ${error.message}`
1486
- );
1750
+ return handleError(error, "count documents", collection.collectionName);
1751
+ }
1752
+ }
1753
+ function handleConvertTime(args) {
1754
+ const { input } = args;
1755
+ let date;
1756
+ if (input === void 0 || input === null || input === "") {
1757
+ date = /* @__PURE__ */ new Date();
1758
+ } else if (typeof input === "number") {
1759
+ date = new Date(Math.abs(input) < 1e12 ? input * 1e3 : input);
1760
+ } else if (typeof input === "string") {
1761
+ const trimmed = input.trim();
1762
+ if (/^-?\d+$/.test(trimmed)) {
1763
+ const n = Number(trimmed);
1764
+ date = new Date(Math.abs(n) < 1e12 ? n * 1e3 : n);
1765
+ } else {
1766
+ date = new Date(trimmed);
1487
1767
  }
1768
+ } else {
1488
1769
  throw new Error(
1489
- `Failed to read collection ${collectionName}: Unknown error`
1770
+ "convertTime 'input' must be a Unix timestamp (number) or a date string"
1771
+ );
1772
+ }
1773
+ if (Number.isNaN(date.getTime())) {
1774
+ throw new Error(
1775
+ `Could not interpret '${String(input)}' as a date or Unix timestamp`
1490
1776
  );
1491
1777
  }
1778
+ const offsetMinutes = -date.getTimezoneOffset();
1779
+ const sign = offsetMinutes >= 0 ? "+" : "-";
1780
+ const absMinutes = Math.abs(offsetMinutes);
1781
+ const offsetLabel = `${sign}${String(Math.floor(absMinutes / 60)).padStart(
1782
+ 2,
1783
+ "0"
1784
+ )}:${String(absMinutes % 60).padStart(2, "0")}`;
1785
+ return formatResponse({
1786
+ iso: date.toISOString(),
1787
+ utc: date.toUTCString(),
1788
+ unixSeconds: Math.floor(date.getTime() / 1e3),
1789
+ unixMillis: date.getTime(),
1790
+ serverLocal: date.toString(),
1791
+ serverTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1792
+ serverUtcOffset: offsetLabel
1793
+ });
1492
1794
  }
1493
- async function handleListResourcesRequest({
1494
- request,
1495
- client,
1496
- db,
1497
- isReadOnlyMode,
1498
- signal
1499
- }) {
1795
+ async function handleListCollections(db, args, objectIdMode = "auto", signal) {
1796
+ const { nameOnly, filter } = args;
1797
+ const skip = args.skip || 0;
1798
+ const limit = args.limit || 20;
1500
1799
  try {
1800
+ const processedFilter = parseFilter(filter, objectIdMode);
1501
1801
  signal?.throwIfAborted();
1502
- const collections = await db.listCollections().toArray();
1503
- const allResources = collections.map((collection) => ({
1504
- uri: `mongodb:///${encodeURIComponent(collection.name)}`,
1505
- mimeType: "application/json",
1506
- name: collection.name,
1507
- description: `MongoDB collection: ${collection.name}`
1508
- }));
1509
- const cursor = request.params?.cursor;
1510
- const { items, nextCursor } = paginate(allResources, cursor);
1511
- return {
1512
- resources: items,
1513
- ...nextCursor ? { nextCursor } : {}
1514
- };
1802
+ const collections = await db.listCollections(processedFilter).toArray();
1803
+ const allResults = nameOnly ? collections.map((collection) => collection.name) : collections;
1804
+ const total = allResults.length;
1805
+ const paged = allResults.slice(skip, skip + limit);
1806
+ return formatResponse({
1807
+ results: paged,
1808
+ metadata: {
1809
+ total,
1810
+ returned: paged.length,
1811
+ skip,
1812
+ limit,
1813
+ hasMore: skip + paged.length < total
1814
+ }
1815
+ });
1515
1816
  } catch (error) {
1516
- if (error instanceof Error) {
1517
- throw new Error(`Failed to list collections: ${error.message}`);
1518
- }
1519
- throw new Error("Failed to list collections: Unknown error");
1817
+ return handleError(error, "list collections");
1520
1818
  }
1521
1819
  }
1522
1820
 
1523
- // src/schemas/templates.ts
1524
- async function handleListResourceTemplatesRequest({
1821
+ // src/schemas/completion.ts
1822
+ async function handleCompletionRequest({
1525
1823
  request,
1526
1824
  client,
1527
1825
  db,
1528
1826
  isReadOnlyMode,
1529
1827
  signal
1530
1828
  }) {
1531
- return {
1532
- resourceTemplates: [
1533
- {
1534
- name: "mongodb_query",
1535
- description: "Template for constructing MongoDB queries",
1536
- uriTemplate: "mongodb:///{collection}",
1537
- text: `To query MongoDB collections, you can use these operators:
1538
-
1539
- Filter operators:
1540
- - $eq: Matches values equal to a specified value
1541
- - $gt/$gte: Matches values greater than (or equal to) a specified value
1542
- - $lt/$lte: Matches values less than (or equal to) a specified value
1543
- - $in: Matches any of the values in an array
1544
- - $nin: Matches none of the values in an array
1545
- - $ne: Matches values not equal to a specified value
1546
- - $exists: Matches documents that have the specified field
1547
-
1548
- Example queries:
1549
- 1. Find documents where age > 21:
1550
- { "age": { "$gt": 21 } }
1551
-
1552
- 2. Find documents with specific status:
1553
- { "status": { "$in": ["active", "pending"] } }
1554
-
1555
- 3. Find documents with existing email:
1556
- { "email": { "$exists": true } }
1557
-
1558
- 4. Find documents with dates:
1559
- // Using ISO date string format
1560
- { "createdAt": { "$gt": "2023-01-01T00:00:00Z" } }
1561
- // Using ISODate syntax
1562
- { "createdAt": { "$gt": ISODate("2023-01-01T00:00:00Z") } }
1563
-
1564
- Use these patterns to construct MongoDB queries.`
1829
+ const { ref, argument } = request.params;
1830
+ if (ref.type === "ref/prompt") {
1831
+ return handlePromptCompletion(
1832
+ client,
1833
+ db,
1834
+ isReadOnlyMode,
1835
+ ref.name,
1836
+ argument,
1837
+ signal
1838
+ );
1839
+ }
1840
+ if (ref.type === "ref/resource") {
1841
+ return handleResourceCompletion(
1842
+ client,
1843
+ db,
1844
+ isReadOnlyMode,
1845
+ ref.uri,
1846
+ argument,
1847
+ signal
1848
+ );
1849
+ }
1850
+ return emptyCompletionResult();
1851
+ }
1852
+ async function handlePromptCompletion(client, db, isReadOnlyMode, promptName, argument, signal) {
1853
+ if (!promptName) {
1854
+ return emptyCompletionResult();
1855
+ }
1856
+ if (argument.name === "collection") {
1857
+ return await completeCollectionNames(
1858
+ argument.value,
1859
+ db,
1860
+ isReadOnlyMode,
1861
+ signal
1862
+ );
1863
+ }
1864
+ return emptyCompletionResult();
1865
+ }
1866
+ async function handleResourceCompletion(client, db, isReadOnlyMode, promptName, argument, signal) {
1867
+ if (!promptName) {
1868
+ return emptyCompletionResult();
1869
+ }
1870
+ if (argument.name === "collection") {
1871
+ return await completeCollectionNames(
1872
+ argument.value,
1873
+ db,
1874
+ isReadOnlyMode,
1875
+ signal
1876
+ );
1877
+ }
1878
+ return emptyCompletionResult();
1879
+ }
1880
+ async function completeCollectionNames(partialValue, db, isReadOnlyMode, signal) {
1881
+ try {
1882
+ console.warn(
1883
+ `Completing collection names with partial value: ${partialValue}`
1884
+ );
1885
+ signal?.throwIfAborted();
1886
+ const collections = await db.listCollections().toArray();
1887
+ const matchingCollections = collections.map(
1888
+ (c) => c.name
1889
+ ).filter(
1890
+ (name) => (
1891
+ // Filter out system collections
1892
+ !name.startsWith("system.") && // Match partial value
1893
+ name.toLowerCase().includes(partialValue.toLowerCase())
1894
+ )
1895
+ ).sort();
1896
+ console.warn(`Found ${matchingCollections.length} matching collections`);
1897
+ const PAGE_SIZE = 100;
1898
+ const { items: limitedResults, nextCursor } = paginate(
1899
+ matchingCollections,
1900
+ void 0,
1901
+ PAGE_SIZE
1902
+ );
1903
+ return {
1904
+ completion: {
1905
+ values: limitedResults,
1906
+ total: matchingCollections.length,
1907
+ hasMore: !!nextCursor
1565
1908
  }
1566
- ]
1909
+ };
1910
+ } catch (error) {
1911
+ console.error("Error completing collection names:", error);
1912
+ return emptyCompletionResult();
1913
+ }
1914
+ }
1915
+ function emptyCompletionResult() {
1916
+ return {
1917
+ completion: {
1918
+ values: [],
1919
+ total: 0,
1920
+ hasMore: false
1921
+ }
1567
1922
  };
1568
1923
  }
1569
1924
 
1570
- // src/schemas/tools.ts
1571
- async function handleListToolsRequest({
1925
+ // src/schemas/ping.ts
1926
+ async function handlePingRequest({
1572
1927
  request,
1573
1928
  client,
1574
1929
  db,
1575
1930
  isReadOnlyMode,
1576
- signal,
1577
- connectionSelection = false
1931
+ signal
1578
1932
  }) {
1579
- const result = {
1580
- tools: [
1581
- {
1582
- name: "query",
1583
- description: "Find documents in one collection using a filter, projection, or sort. Use aggregate for grouping or joins. Request an execution plan to inspect the query.",
1584
- inputSchema: {
1585
- type: "object",
1586
- properties: {
1587
- collection: {
1588
- type: "string",
1589
- description: "Name of the collection to query"
1590
- },
1591
- filter: {
1592
- type: "object",
1593
- description: "MongoDB query filter. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation"
1594
- },
1595
- projection: {
1596
- type: "object",
1597
- description: "Fields to include/exclude"
1598
- },
1599
- limit: {
1600
- type: "number",
1601
- description: "Maximum number of documents to return",
1602
- default: 10
1603
- },
1604
- skip: {
1605
- type: "number",
1606
- description: "Number of documents to skip before returning results",
1607
- default: 0
1608
- },
1609
- sort: {
1610
- type: "object",
1611
- description: "Sort order as a field-to-direction map, e.g. { orderDate: -1 } for descending or { name: 1 } for ascending"
1612
- },
1613
- explain: {
1614
- type: "string",
1615
- description: "Optional: Get query execution information",
1616
- enum: ["queryPlanner", "executionStats", "allPlansExecution"]
1617
- },
1618
- objectIdMode: {
1619
- type: "string",
1620
- description: "Control how 24-character hex strings are handled",
1621
- enum: ["auto", "none", "force"],
1622
- default: "auto"
1623
- }
1624
- },
1625
- required: ["collection"]
1626
- }
1627
- },
1628
- {
1629
- name: "aggregate",
1630
- description: "Run a MongoDB aggregation pipeline for grouping, joins, or multi-stage transformations. Request an execution plan to inspect the pipeline.",
1631
- inputSchema: {
1632
- type: "object",
1633
- properties: {
1634
- collection: {
1635
- type: "string",
1636
- description: "Name of the collection to aggregate"
1637
- },
1638
- pipeline: {
1639
- type: "array",
1640
- description: "Aggregation pipeline stages. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation",
1641
- items: {
1642
- type: "object"
1643
- }
1644
- },
1645
- explain: {
1646
- type: "string",
1647
- description: "Optional: Get aggregation execution information (queryPlanner, executionStats, or allPlansExecution)",
1648
- enum: ["queryPlanner", "executionStats", "allPlansExecution"]
1649
- },
1650
- objectIdMode: {
1651
- type: "string",
1652
- description: "Control how 24-character hex strings are handled",
1653
- enum: ["auto", "none", "force"],
1654
- default: "auto"
1655
- }
1656
- },
1657
- required: ["collection", "pipeline"]
1658
- }
1659
- },
1660
- {
1661
- name: "update",
1662
- description: "Modify matching documents using operators such as $set, $unset, or $inc. Enable multi to update all matches or upsert to create a document when the filter matches none.",
1663
- inputSchema: {
1664
- type: "object",
1665
- properties: {
1666
- collection: {
1667
- type: "string",
1668
- description: "Name of the collection to update"
1669
- },
1670
- filter: {
1671
- type: "object",
1672
- description: "Filter to select documents to update. Supports date strings in ISO format ('2025-01-01T00:00:00Z') and ISODate('2025-01-01T00:00:00Z') notation"
1673
- },
1674
- update: {
1675
- type: "object",
1676
- description: "Update operations to apply ($set, $unset, $inc, etc.)"
1677
- },
1678
- upsert: {
1679
- type: "boolean",
1680
- description: "Create a new document if no documents match the filter"
1681
- },
1682
- multi: {
1683
- type: "boolean",
1684
- description: "Update multiple documents that match the filter"
1685
- },
1686
- objectIdMode: {
1687
- type: "string",
1688
- description: "Control how 24-character hex strings are handled",
1689
- enum: ["auto", "none", "force"],
1690
- default: "auto"
1691
- }
1692
- },
1693
- required: ["collection", "filter", "update"]
1694
- }
1695
- },
1696
- {
1697
- name: "serverInfo",
1698
- description: "Inspect MongoDB server metadata such as version and storage engine. Enable includeDebugInfo for additional diagnostics.",
1699
- inputSchema: {
1700
- type: "object",
1701
- properties: {
1702
- includeDebugInfo: {
1703
- type: "boolean",
1704
- description: "Include additional debug information about the server"
1705
- }
1706
- }
1707
- }
1708
- },
1709
- {
1710
- name: "insert",
1711
- description: "Insert new documents into a MongoDB collection. Use update to modify existing records.",
1712
- inputSchema: {
1713
- type: "object",
1714
- properties: {
1715
- collection: {
1716
- type: "string",
1717
- description: "Name of the collection to insert into"
1718
- },
1719
- documents: {
1720
- type: "array",
1721
- description: "Array of documents to insert",
1722
- items: { type: "object" }
1723
- },
1724
- ordered: {
1725
- type: "boolean",
1726
- description: "If true, perform ordered insert. If false, insert unordered"
1727
- },
1728
- writeConcern: {
1729
- type: "object",
1730
- description: "Write concern for the insert operation"
1731
- },
1732
- bypassDocumentValidation: {
1733
- type: "boolean",
1734
- description: "Allow insert to bypass schema validation"
1735
- },
1736
- objectIdMode: {
1737
- type: "string",
1738
- description: "Control how 24-character hex strings are handled",
1739
- enum: ["auto", "none", "force"],
1740
- default: "auto"
1741
- }
1742
- },
1743
- required: ["collection", "documents"]
1744
- }
1745
- },
1746
- {
1747
- name: "createIndex",
1748
- description: "Create indexes to improve query performance, enforce uniqueness, or expire documents with a TTL index.",
1749
- inputSchema: {
1750
- type: "object",
1751
- properties: {
1752
- collection: {
1753
- type: "string",
1754
- description: "Name of the collection to create indexes on"
1755
- },
1756
- indexes: {
1757
- type: "array",
1758
- description: "Array of index specifications",
1759
- items: {
1760
- type: "object",
1761
- properties: {
1762
- key: {
1763
- type: "object",
1764
- description: "Index key pattern, e.g. { field: 1 }"
1765
- },
1766
- name: {
1767
- type: "string",
1768
- description: "Optional: Name of the index"
1769
- },
1770
- unique: {
1771
- type: "boolean",
1772
- description: "Optional: Creates a unique index"
1773
- },
1774
- sparse: {
1775
- type: "boolean",
1776
- description: "Optional: Creates a sparse index"
1777
- },
1778
- background: {
1779
- type: "boolean",
1780
- description: "Optional: Builds index in background"
1781
- },
1782
- expireAfterSeconds: {
1783
- type: "number",
1784
- description: "TTL in seconds for documents"
1785
- },
1786
- partialFilterExpression: {
1787
- type: "object",
1788
- description: "Filter expression for partial indexes"
1789
- }
1790
- },
1791
- required: ["key"]
1792
- }
1793
- },
1794
- writeConcern: {
1795
- type: "object",
1796
- description: "Write concern for index creation"
1797
- },
1798
- commitQuorum: {
1799
- type: ["string", "number"],
1800
- description: "Replica-set commit quorum as a member count or a mode such as majority"
1801
- },
1802
- objectIdMode: {
1803
- type: "string",
1804
- description: "Control how 24-character hex strings are handled",
1805
- enum: ["auto", "none", "force"],
1806
- default: "auto"
1807
- }
1808
- },
1809
- required: ["collection", "indexes"]
1810
- }
1811
- },
1933
+ try {
1934
+ if (!client) {
1935
+ throw new Error("MongoDB connection is not available");
1936
+ }
1937
+ signal?.throwIfAborted();
1938
+ const pong = await db.command({ ping: 1 });
1939
+ if (pong.ok !== 1) {
1940
+ throw new Error(`MongoDB ping failed: ${pong.errmsg}`);
1941
+ }
1942
+ return {};
1943
+ } catch (error) {
1944
+ if (error instanceof Error) {
1945
+ throw new Error(`MongoDB ping failed: ${error.message}`);
1946
+ }
1947
+ throw new Error("MongoDB ping failed: Unknown error");
1948
+ }
1949
+ }
1950
+
1951
+ // src/schemas/prompts.ts
1952
+ async function handleListPromptsRequest({
1953
+ request,
1954
+ client,
1955
+ db,
1956
+ isReadOnlyMode,
1957
+ signal,
1958
+ connectionSelection = false
1959
+ }) {
1960
+ return {
1961
+ prompts: [
1812
1962
  {
1813
- name: "count",
1814
- description: "Count documents matching a filter in one collection. Use aggregate for counts grouped by field.",
1815
- inputSchema: {
1816
- type: "object",
1817
- properties: {
1818
- collection: { type: "string", description: "Collection name" },
1819
- query: { type: "object", description: "Query filter to count" },
1820
- limit: { type: "integer", description: "Max documents to count" },
1821
- skip: {
1822
- type: "integer",
1823
- description: "Docs to skip before counting"
1824
- },
1825
- hint: { type: "object", description: "Index hint" },
1826
- readConcern: { type: "object", description: "Read concern option" },
1827
- maxTimeMS: { type: "integer", description: "Max execution time" },
1828
- collation: {
1829
- type: "object",
1830
- description: "Collation rules for comparison"
1831
- },
1832
- objectIdMode: {
1833
- type: "string",
1834
- description: "Control how 24-character hex strings are handled",
1835
- enum: ["auto", "none", "force"],
1836
- default: "auto"
1837
- }
1963
+ name: "analyze_collection",
1964
+ description: "Analyze a MongoDB collection structure and contents",
1965
+ arguments: [
1966
+ {
1967
+ name: "collection",
1968
+ description: "Name of the collection to analyze",
1969
+ required: true
1838
1970
  },
1839
- required: ["collection"]
1840
- }
1841
- },
1842
- {
1843
- name: "listCollections",
1844
- description: "List collections in the connected database. Filter by name or other collection properties and request names only for a compact result.",
1845
- inputSchema: {
1846
- type: "object",
1847
- properties: {
1848
- nameOnly: {
1849
- type: "boolean",
1850
- description: "If true, return only collection names"
1851
- },
1852
- filter: {
1853
- type: "object",
1854
- description: "Filter for collections"
1855
- },
1856
- skip: {
1857
- type: "number",
1858
- description: "Number of collections to skip before returning results",
1859
- default: 0
1860
- },
1861
- limit: {
1862
- type: "number",
1863
- description: "Maximum number of collections to return",
1864
- default: 20
1971
+ ...connectionSelection ? [
1972
+ {
1973
+ name: "connectionId",
1974
+ description: "Configured MongoDB connection ID"
1865
1975
  },
1866
- objectIdMode: {
1867
- type: "string",
1868
- description: "Control how 24-character hex strings are handled",
1869
- enum: ["auto", "none", "force"],
1870
- default: "auto"
1871
- }
1872
- }
1873
- }
1874
- },
1875
- {
1876
- name: "convertTime",
1877
- description: "Convert a Unix timestamp or date string to multiple formats (UTC ISO 8601, GMT, Unix seconds/milliseconds) and report the server's current time and timezone. Useful for building unambiguous date queries when the server and user are in different timezones. Omit 'input' to get the current time.",
1878
- inputSchema: {
1879
- type: "object",
1880
- properties: {
1881
- input: {
1882
- type: "string",
1883
- description: "A Unix timestamp (seconds or milliseconds, as a number or numeric string) or a date string (ISO 8601, with or without a timezone offset). Omit to use the current server time."
1976
+ {
1977
+ name: "database",
1978
+ description: "Allowed MongoDB database name"
1884
1979
  }
1885
- }
1886
- }
1980
+ ] : []
1981
+ ]
1887
1982
  }
1888
1983
  ]
1889
1984
  };
1890
- const { tools } = result;
1891
- if (connectionSelection) {
1892
- const connectionId = {
1893
- type: "string",
1894
- description: "Configured connection ID. Defaults to default."
1895
- };
1896
- const database = {
1897
- type: "string",
1898
- description: "Allowed database name. Defaults to the database in the connection URI."
1899
- };
1900
- for (const tool of tools) {
1901
- if (tool.name !== "convertTime") {
1902
- tool.inputSchema.properties = {
1903
- ...tool.inputSchema.properties,
1904
- connectionId,
1905
- database
1906
- };
1907
- }
1985
+ }
1986
+ async function handleGetPromptRequest({
1987
+ request,
1988
+ client,
1989
+ db,
1990
+ isReadOnlyMode,
1991
+ signal,
1992
+ sendProgress,
1993
+ connectionId,
1994
+ showTarget = false
1995
+ }) {
1996
+ const { name, arguments: args = {} } = request.params;
1997
+ if (name !== "analyze_collection") {
1998
+ throw new Error("Unknown prompt");
1999
+ }
2000
+ const collectionName = args.collection;
2001
+ if (!collectionName) {
2002
+ throw new Error("Collection name is required");
2003
+ }
2004
+ try {
2005
+ const collection = db.collection(collectionName);
2006
+ if (collection.collectionName.startsWith("system.")) {
2007
+ throw new Error("Access to system collections is not allowed");
1908
2008
  }
1909
- tools.push(
1910
- {
1911
- name: "listConnections",
1912
- description: "List configured connection IDs, default databases, and read-only policies.",
1913
- inputSchema: { type: "object", properties: {} }
1914
- },
2009
+ await sendProgress?.(1, 3, "Getting schema");
2010
+ signal?.throwIfAborted();
2011
+ const schemaSample = await collection.findOne({});
2012
+ await sendProgress?.(2, 3, "Getting stats");
2013
+ signal?.throwIfAborted();
2014
+ const stats = await collection.aggregate([{ $collStats: { count: {} } }]).toArray();
2015
+ await sendProgress?.(3, 3, "Fetching samples");
2016
+ signal?.throwIfAborted();
2017
+ const sampleDocs = await collection.find({}).limit(5).toArray();
2018
+ const documentCount = stats[0]?.count ?? "unknown";
2019
+ return {
2020
+ messages: [
2021
+ {
2022
+ role: "user",
2023
+ content: {
2024
+ type: "text",
2025
+ text: `Please analyze the following MongoDB collection:
2026
+ Collection: ${collectionName}${showTarget ? `
2027
+ Connection: ${connectionId}
2028
+ Database: ${db.databaseName}` : ""}
2029
+
2030
+ Schema:
2031
+ ${JSON.stringify(schemaSample, null, 2)}
2032
+
2033
+ Stats:
2034
+ Document count: ${documentCount}
2035
+
2036
+ Sample documents:
2037
+ ${JSON.stringify(sampleDocs, null, 2)}`
2038
+ }
2039
+ },
2040
+ {
2041
+ role: "user",
2042
+ content: {
2043
+ type: "text",
2044
+ text: "Provide insights about the collection's structure, data types, and basic statistics."
2045
+ }
2046
+ }
2047
+ ]
2048
+ };
2049
+ } catch (error) {
2050
+ const msg = error instanceof Error ? error.message : "Unknown error";
2051
+ throw new Error(`Failed to analyze collection ${collectionName}: ${msg}`);
2052
+ }
2053
+ }
2054
+
2055
+ // src/schemas/templates.ts
2056
+ async function handleListResourceTemplatesRequest({
2057
+ request,
2058
+ client,
2059
+ db,
2060
+ isReadOnlyMode,
2061
+ signal
2062
+ }) {
2063
+ return {
2064
+ resourceTemplates: [
1915
2065
  {
1916
- name: "listDatabases",
1917
- description: "List existing authorized databases on a configured connection.",
1918
- inputSchema: { type: "object", properties: { connectionId } }
2066
+ name: "mongodb_query",
2067
+ description: "Template for constructing MongoDB queries",
2068
+ uriTemplate: "mongodb:///{collection}",
2069
+ text: `To query MongoDB collections, you can use these operators:
2070
+
2071
+ Filter operators:
2072
+ - $eq: Matches values equal to a specified value
2073
+ - $gt/$gte: Matches values greater than (or equal to) a specified value
2074
+ - $lt/$lte: Matches values less than (or equal to) a specified value
2075
+ - $in: Matches any of the values in an array
2076
+ - $nin: Matches none of the values in an array
2077
+ - $ne: Matches values not equal to a specified value
2078
+ - $exists: Matches documents that have the specified field
2079
+
2080
+ Example queries:
2081
+ 1. Find documents where age > 21:
2082
+ { "age": { "$gt": 21 } }
2083
+
2084
+ 2. Find documents with specific status:
2085
+ { "status": { "$in": ["active", "pending"] } }
2086
+
2087
+ 3. Find documents with existing email:
2088
+ { "email": { "$exists": true } }
2089
+
2090
+ 4. Find documents with dates:
2091
+ // Using ISO date string format
2092
+ { "createdAt": { "$gt": "2023-01-01T00:00:00Z" } }
2093
+ // Using ISODate syntax
2094
+ { "createdAt": { "$gt": ISODate("2023-01-01T00:00:00Z") } }
2095
+
2096
+ Use these patterns to construct MongoDB queries.`
1919
2097
  }
1920
- );
1921
- }
1922
- return result;
2098
+ ]
2099
+ };
1923
2100
  }
1924
2101
 
1925
2102
  // src/server.ts
@@ -1930,13 +2107,13 @@ function createSendProgress(progressToken, notify) {
1930
2107
  params: { progressToken, progress, total, message }
1931
2108
  });
1932
2109
  }
1933
- function createServer(client, db, isReadOnlyMode = false, allowCrossDb = false, allowServerJs = false, options = {}, connections2) {
2110
+ function createServer(client, db, isReadOnlyMode = false, allowCrossDb = false, allowServerJs = false, options = {}, connections2, queryLimits) {
1934
2111
  const defaultTarget = { client, db, isReadOnlyMode };
1935
2112
  const server = new Server(
1936
2113
  {
1937
2114
  name: "mongodb",
1938
2115
  title: "MongoDB MCP Server",
1939
- version: "3.1.0",
2116
+ version: "3.2.1",
1940
2117
  description: "MCP server for MongoDB: query, aggregate, and manage collections with read-only mode, progress notifications, and cancellation",
1941
2118
  websiteUrl: "https://github.com/kiliczsh/mcp-mongo-server",
1942
2119
  ...options
@@ -1997,6 +2174,8 @@ function createServer(client, db, isReadOnlyMode = false, allowCrossDb = false,
1997
2174
  db,
1998
2175
  isReadOnlyMode,
1999
2176
  connectionSelection: !!connections2,
2177
+ hasWritableConnection: connections2?.list().some(({ readOnly }) => !readOnly),
2178
+ queryLimits,
2000
2179
  signal: ctx.mcpReq.signal
2001
2180
  })
2002
2181
  );
@@ -2010,6 +2189,7 @@ function createServer(client, db, isReadOnlyMode = false, allowCrossDb = false,
2010
2189
  allowCrossDb,
2011
2190
  allowServerJs,
2012
2191
  connections: connections2,
2192
+ queryLimits,
2013
2193
  signal: ctx.mcpReq.signal
2014
2194
  })
2015
2195
  );
@@ -2174,6 +2354,7 @@ async function main() {
2174
2354
  connectionUrl = process.env.MCP_MONGODB_URI || "";
2175
2355
  }
2176
2356
  try {
2357
+ const queryLimits = readQueryLimits(process.env);
2177
2358
  connections = new Connections(
2178
2359
  connectionUrl,
2179
2360
  process.env.MCP_MONGODB_CONNECTIONS,
@@ -2188,6 +2369,7 @@ async function main() {
2188
2369
  allowCrossDb,
2189
2370
  allowServerJs,
2190
2371
  connections,
2372
+ queryLimits,
2191
2373
  port,
2192
2374
  allowedOrigins,
2193
2375
  jsonLimit,
@@ -2200,16 +2382,17 @@ async function main() {
2200
2382
  isReadOnlyMode,
2201
2383
  allowCrossDb,
2202
2384
  allowServerJs,
2203
- connections
2385
+ connections,
2386
+ queryLimits
2204
2387
  );
2205
2388
  }
2206
2389
  } catch (error) {
2207
- console.error("Failed to connect to MongoDB:", error);
2390
+ console.error("Failed to start MongoDB MCP server:", error);
2208
2391
  await connections?.close();
2209
2392
  process.exit(1);
2210
2393
  }
2211
2394
  }
2212
- async function startStdioServer(client, db, isReadOnlyMode, allowCrossDb, allowServerJs, connections2) {
2395
+ async function startStdioServer(client, db, isReadOnlyMode, allowCrossDb, allowServerJs, connections2, queryLimits) {
2213
2396
  serveStdio(
2214
2397
  () => createServer(
2215
2398
  client,
@@ -2218,7 +2401,8 @@ async function startStdioServer(client, db, isReadOnlyMode, allowCrossDb, allowS
2218
2401
  allowCrossDb,
2219
2402
  allowServerJs,
2220
2403
  {},
2221
- connections2
2404
+ connections2,
2405
+ queryLimits
2222
2406
  )
2223
2407
  );
2224
2408
  console.warn("Server connected successfully via stdio");
@@ -2246,7 +2430,7 @@ function bearerTokenValid(authorization, expected) {
2246
2430
  const wanted = Buffer.from(expected);
2247
2431
  return provided.length === wanted.length && timingSafeEqual(provided, wanted);
2248
2432
  }
2249
- async function startHttpServer(client, db, isReadOnlyMode, allowCrossDb, allowServerJs, connections2, port, allowedOrigins, jsonLimit, authToken) {
2433
+ async function startHttpServer(client, db, isReadOnlyMode, allowCrossDb, allowServerJs, connections2, queryLimits, port, allowedOrigins, jsonLimit, authToken) {
2250
2434
  const app = createMcpExpressApp({ host: "0.0.0.0", jsonLimit });
2251
2435
  app.use((req, res, next) => {
2252
2436
  const start = Date.now();
@@ -2304,7 +2488,8 @@ async function startHttpServer(client, db, isReadOnlyMode, allowCrossDb, allowSe
2304
2488
  allowCrossDb,
2305
2489
  allowServerJs,
2306
2490
  {},
2307
- connections2
2491
+ connections2,
2492
+ queryLimits
2308
2493
  )
2309
2494
  );
2310
2495
  const nodeHandler = toNodeHandler(mcpHandler, {