MongoDB MCP Server

  • src
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { MongoClient, Sort } from 'mongodb'; /** * MongoDB MCP server providing read-only access to MongoDB databases. * Exposes MongoDB operations as MCP tools with safety validation. */ class MongoDBServer { private server: Server; private client: MongoClient | null = null; private defaultDatabase: string | null = null; /** * Initializes the MongoDB MCP server. * Sets up server configuration, tools, and event handlers. * Uses MONGODB_URI and optional MONGODB_DEFAULT_DATABASE env vars. */ constructor() { this.server = new Server( { name: 'mongodb-server', version: '0.1.0', description: `MongoDB MCP server providing read-only access to MongoDB databases. Connection Requirements: - MONGODB_URI environment variable must be set with a valid MongoDB connection string - Optional MONGODB_DEFAULT_DATABASE environment variable for default database - Connection string should include authentication credentials if required - Network access to MongoDB server must be available Best Practices: - Start with schema discovery before complex queries - Use limits when querying large collections - Use projections to fetch only needed fields - Remember all operations are read-only for safety - Create appropriate indexes for text search and frequent queries Error Handling: - If a query fails, verify collection existence and schema - Check query syntax matches MongoDB's query operators - Ensure aggregation pipelines only use allowed stages - Review explain plans for slow queries - Verify text indexes exist before performing text searches`, }, { capabilities: { tools: {}, }, } ); const defaultDb = process.env.MONGODB_DEFAULT_DATABASE; if (defaultDb) { this.defaultDatabase = defaultDb; console.error(`Using default database: ${defaultDb}`); } this.setupToolHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.disconnect(); await this.server.close(); process.exit(0); }); } /** * Establishes MongoDB connection using URI from environment. * @returns Connected MongoDB client * @throws {McpError} If MONGODB_URI is not configured */ private async connect() { if (!this.client) { const uri = process.env.MONGODB_URI; if (!uri) { throw new McpError( ErrorCode.InvalidRequest, 'MONGODB_URI environment variable is required' ); } this.client = new MongoClient(uri); await this.client.connect(); } return this.client; } /** * Safely closes MongoDB connection. */ private async disconnect() { if (this.client) { await this.client.close(); this.client = null; } } /** * Infers schema structure from document samples. * @param documents - Sample documents to analyze * @param path - Current object path for nested fields * @param schema - Accumulated schema object * @returns Inferred schema with types and examples */ private async inferSchema(documents: any[], path = '', schema: any = {}) { for (const doc of documents) { Object.entries(doc).forEach(([key, value]) => { const fullPath = path ? `${path}.${key}` : key; if (Array.isArray(value)) { if (!schema[fullPath]) { schema[fullPath] = { type: 'array' }; } // Handle empty arrays if (value.length === 0) { schema[fullPath].items = { type: 'unknown' }; } // Handle arrays of primitives else if (typeof value[0] !== 'object' || value[0] === null) { schema[fullPath].items = { type: typeof value[0] }; } // Handle arrays of objects else { schema[fullPath].items = { type: 'object', properties: {} }; this.inferSchema(value, `${fullPath}.items.properties`, schema); } } else if (value && typeof value === 'object') { if (!schema[fullPath]) { schema[fullPath] = { type: 'object', properties: {} }; } this.inferSchema([value], `${fullPath}.properties`, schema); } else { if (!schema[fullPath]) { schema[fullPath] = { type: typeof value, example: value, // Add example value for better understanding nullable: value === null }; } } }); } return schema; } /** * Validates aggregation pipeline for safety. * Prevents stages that could modify data. * @param pipeline - MongoDB aggregation pipeline * @throws {McpError} If pipeline contains unsafe operations */ private validateAggregationPipeline(pipeline: any[]): void { if (!Array.isArray(pipeline)) { throw new McpError( ErrorCode.InvalidRequest, 'Aggregation pipeline must be an array' ); } if (pipeline.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'Aggregation pipeline cannot be empty' ); } const unsafeStages = ['$out', '$merge', '$addFields', '$set', '$unset', '$replaceRoot', '$replaceWith']; const unsafeStageFound = pipeline.find(stage => Object.keys(stage).some(key => unsafeStages.includes(key)) ); if (unsafeStageFound) { throw new McpError( ErrorCode.InvalidRequest, 'Pipeline contains unsafe stages that could modify data. Only read-only operations are allowed.' ); } } /** * Generates visualization suggestions based on result data. * @param data - Query results to analyze * @returns Recommended visualization approaches based on data characteristics */ private generateVisualizationHint(data: any[]): string { if (!Array.isArray(data) || data.length === 0) return ''; // Check if the data looks like time series const hasDateFields = Object.keys(data[0]).some(key => data[0][key] instanceof Date || (typeof data[0][key] === 'string' && !isNaN(Date.parse(data[0][key]))) ); // Check if the data has numeric fields const numericFields = Object.keys(data[0]).filter(key => typeof data[0][key] === 'number' ); // Check if the data has categorical fields const categoricalFields = Object.keys(data[0]).filter(key => typeof data[0][key] === 'string' && data.every(item => typeof item[key] === 'string') ); // Check if the data has geospatial fields const hasGeoData = Object.keys(data[0]).some(key => { const value = data[0][key]; return value && typeof value === 'object' && (('type' in value && value.type === 'Point' && 'coordinates' in value) || (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number')); }); let hints = []; if (hasDateFields && numericFields.length > 0) { hints.push('Time Series Visualization:\n- Consider line charts for temporal trends\n- Time-based heat maps for density patterns\n- Area charts for cumulative values over time'); } if (categoricalFields.length > 0 && numericFields.length > 0) { hints.push('Categorical Analysis:\n- Bar charts for comparing categories\n- Box plots for distribution analysis\n- Heat maps for category correlations\n- Treemaps for hierarchical data'); } if (numericFields.length >= 2) { hints.push('Numerical Analysis:\n- Scatter plots for correlation analysis\n- Bubble charts if three numeric dimensions\n- Correlation matrices for multiple variables\n- Histograms for distribution analysis'); } if (hasGeoData) { hints.push('Geospatial Visualization:\n- Map overlays for location data\n- Choropleth maps for regional analysis\n- Heat maps for density visualization\n- Cluster maps for point concentration'); } if (data.length > 1000) { hints.push('Large Dataset Considerations:\n- Consider sampling for initial visualization\n- Use aggregation for summary views\n- Implement pagination or infinite scroll\n- Consider server-side rendering'); } return hints.join('\n\n'); } /** * Converts MongoDB documents to CSV format. * @param docs - MongoDB documents to convert * @param options - Format options like header inclusion, delimiter * @returns CSV formatted string */ private documentsToCsv(docs: any[], options: { includeHeaders?: boolean; delimiter?: string; } = {}): string { if (!Array.isArray(docs) || docs.length === 0) return ''; const delimiter = options.delimiter || ','; const includeHeaders = options.includeHeaders !== false; // Extract all possible field names from all documents (handles varying schemas) const fieldsSet = new Set<string>(); docs.forEach(doc => { Object.keys(doc).forEach(key => fieldsSet.add(key)); }); const fields = Array.from(fieldsSet); let result = ''; // Add headers if (includeHeaders) { result += fields.map(field => this.escapeCsvField(field, delimiter)).join(delimiter) + '\n'; } // Add data rows docs.forEach(doc => { const row = fields.map(field => { const value = doc[field]; if (value === undefined || value === null) return ''; if (typeof value === 'object') return this.escapeCsvField(JSON.stringify(value), delimiter); return this.escapeCsvField(String(value), delimiter); }); result += row.join(delimiter) + '\n'; }); return result; } /** * Escapes a field for CSV format. * @param field - Field value to escape * @param delimiter - CSV delimiter character * @returns Escaped field value */ private escapeCsvField(field: string, delimiter: string): string { // If field contains delimiter, newline, or quotes, wrap in quotes and escape internal quotes if (field.includes(delimiter) || field.includes('\n') || field.includes('"')) { return `"${field.replace(/"/g, '""')}"`; } return field; } /** * Registers all MongoDB tool handlers with the MCP server. * Sets up request handlers for listing available tools and executing them. */ private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'list_databases', description: 'List all databases in the MongoDB server.', inputSchema: { type: 'object', properties: {}, }, }, { name: 'list_collections', description: `List all collections in a database. Start here to understand what collections are available before querying.`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, }, }, }, { name: 'get_schema', description: `Infer schema from a collection by analyzing sample documents. Best Practice: Use this before querying to understand collection structure. Example: use_mcp_tool with server_name: "mongodb", tool_name: "get_schema", arguments: { "collection": "users", "sampleSize": 100 }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, sampleSize: { type: 'number', description: 'Number of documents to sample (default: 100)', minimum: 1, maximum: 1000, }, }, required: ['collection'], }, }, { name: 'query', description: `Execute a read-only query on a collection using MongoDB query syntax. Supports both JSON and CSV output formats: - Use outputFormat="json" for standard JSON (default) - Use outputFormat="csv" for comma-separated values export Best Practices: - Use projections to fetch only needed fields - Add limits for large collections - Use sort for consistent ordering Example - Standard Query: use_mcp_tool with server_name: "mongodb", tool_name: "query", arguments: { "collection": "users", "filter": { "age": { "$gte": 21 } }, "projection": { "name": 1, "email": 1 }, "sort": { "name": 1 }, "limit": 100 } Example - CSV Export: use_mcp_tool with server_name: "mongodb", tool_name: "query", arguments: { "collection": "users", "filter": { "active": true }, "outputFormat": "csv", "formatOptions": { "includeHeaders": true, "delimiter": "," } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, filter: { type: 'object', description: 'MongoDB query filter using standard MongoDB operators ($eq, $gt, $in, etc.)', }, projection: { type: 'object', description: 'MongoDB projection to specify fields to return (optional)', }, sort: { type: 'object', description: 'MongoDB sort specification (optional)', }, limit: { type: 'number', description: 'Maximum number of documents to return (optional)', minimum: 1, maximum: 1000, }, outputFormat: { type: 'string', description: 'Output format for results (json or csv)', enum: ['json', 'csv'], }, formatOptions: { type: 'object', description: 'Format-specific options', properties: { delimiter: { type: 'string', description: 'CSV delimiter character (default: comma)', }, includeHeaders: { type: 'boolean', description: 'Whether to include header row in CSV (default: true)', }, }, }, }, required: ['collection', 'filter'], }, }, { name: 'aggregate', description: `Execute a read-only aggregation pipeline on a collection. Supported Stages: - $match: Filter documents - $group: Group documents by a key - $sort: Sort documents - $project: Shape the output - $lookup: Perform left outer joins - $unwind: Deconstruct array fields Unsafe/Blocked Stages: - $out: Write results to collection - $merge: Merge results into collection - $addFields: Add new fields - $set: Set field values - $unset: Remove fields - $replaceRoot: Replace document structure - $replaceWith: Replace document Example - User Statistics by Role: use_mcp_tool with server_name: "mongodb", tool_name: "aggregate", arguments: { "collection": "users", "pipeline": [ { "$match": { "active": true } }, { "$group": { "_id": "$role", "count": { "$sum": 1 }, "avgAge": { "$avg": "$age" } }}, { "$sort": { "count": -1 } } ], "limit": 100 } Example - Posts with Author Details: use_mcp_tool with server_name: "mongodb", tool_name: "aggregate", arguments: { "collection": "posts", "pipeline": [ { "$match": { "published": true } }, { "$lookup": { "from": "users", "localField": "authorId", "foreignField": "_id", "as": "author" }}, { "$unwind": "$author" }, { "$project": { "title": 1, "authorName": "$author.name", "publishDate": 1 }} ] }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, pipeline: { type: 'array', description: 'MongoDB aggregation pipeline stages (read-only operations only)', items: { type: 'object', }, }, limit: { type: 'number', description: 'Maximum number of documents to return (optional)', minimum: 1, maximum: 1000, }, }, required: ['collection', 'pipeline'], }, }, { name: 'get_collection_stats', description: `Get detailed statistics about a collection. Returns information about: - Document count and size - Storage metrics - Index sizes and usage - Average document size - Padding factor`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, }, required: ['collection'], }, }, { name: 'get_indexes', description: `Get information about indexes on a collection. Returns details about: - Index names and fields - Index types (single field, compound, text, etc.) - Index sizes - Index options - Usage statistics`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, }, required: ['collection'], }, }, { name: 'explain_query', description: `Get the execution plan for a query. Helps understand: - How MongoDB will execute the query - Which indexes will be used - Number of documents examined - Execution stages and timing Use this to optimize slow queries.`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, filter: { type: 'object', description: 'MongoDB query filter to explain', }, projection: { type: 'object', description: 'MongoDB projection (optional)', }, sort: { type: 'object', description: 'MongoDB sort specification (optional)', }, }, required: ['collection', 'filter'], }, }, { name: 'get_distinct_values', description: `Get distinct values for a field in a collection. Useful for: - Understanding data distribution - Finding unique categories - Data quality checks - Identifying outliers Example: use_mcp_tool with server_name: "mongodb", tool_name: "get_distinct_values", arguments: { "collection": "users", "field": "role", "filter": { "active": true } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, field: { type: 'string', description: 'Field name to get distinct values for', }, filter: { type: 'object', description: 'MongoDB query filter to apply before getting distinct values (optional)', }, }, required: ['collection', 'field'], }, }, { name: 'sample_data', description: `Get a random sample of documents from a collection. Supports both JSON and CSV output formats: - Use outputFormat="json" for standard JSON (default) - Use outputFormat="csv" for comma-separated values export Useful for: - Exploratory data analysis - Testing with representative data - Understanding data distribution - Performance testing with realistic data subsets Example - JSON Sample: use_mcp_tool with server_name: "mongodb", tool_name: "sample_data", arguments: { "collection": "users", "size": 50 } Example - CSV Export: use_mcp_tool with server_name: "mongodb", tool_name: "sample_data", arguments: { "collection": "users", "size": 100, "outputFormat": "csv", "formatOptions": { "includeHeaders": true, "delimiter": "," } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, size: { type: 'number', description: 'Number of random documents to sample (default: 10)', minimum: 1, maximum: 1000, }, outputFormat: { type: 'string', description: 'Output format for results (json or csv)', enum: ['json', 'csv'], }, formatOptions: { type: 'object', description: 'Format-specific options', properties: { delimiter: { type: 'string', description: 'CSV delimiter character (default: comma)', }, includeHeaders: { type: 'boolean', description: 'Whether to include header row in CSV (default: true)', }, }, }, }, required: ['collection'], }, }, { name: 'count_documents', description: `Count documents in a collection that match a filter. Benefits: - More efficient than retrieving full documents - Good for understanding data volume - Can help planning query strategies - Optimize pagination implementation Example: use_mcp_tool with server_name: "mongodb", tool_name: "count_documents", arguments: { "collection": "users", "filter": { "active": true, "age": { "$gte": 21 } } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, filter: { type: 'object', description: 'MongoDB query filter (optional, defaults to count all documents)', }, }, required: ['collection'], }, }, { name: 'find_by_ids', description: `Find multiple documents by their IDs in a single request. Advantages: - More efficient than multiple single document lookups - Preserves ID order in results when possible - Can filter specific fields with projection - Handles both string and ObjectId identifiers Example: use_mcp_tool with server_name: "mongodb", tool_name: "find_by_ids", arguments: { "collection": "products", "ids": ["5f8d0f3c", "5f8d0f3d", "5f8d0f3e"], "idField": "_id", "projection": { "name": 1, "price": 1 } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, ids: { type: 'array', description: 'Array of document IDs to look up', items: { type: ['string', 'number'], }, }, idField: { type: 'string', description: 'Field containing the IDs (default: "_id")', }, projection: { type: 'object', description: 'MongoDB projection to specify fields to return (optional)', }, }, required: ['collection', 'ids'], }, }, { name: 'geo_query', description: `Execute geospatial queries on a MongoDB collection. Supports: - Finding points near a location - Finding documents within a polygon, circle, or box - Calculating distances between points - GeoJSON and legacy coordinate pair formats Requirements: - Collection must have a geospatial index (2dsphere recommended) - Coordinates should follow MongoDB conventions (longitude first, then latitude) Examples: 1. Find locations near a point (2 miles radius): use_mcp_tool with server_name: "mongodb", tool_name: "geo_query", arguments: { "collection": "restaurants", "operation": "near", "point": [-73.9667, 40.78], "maxDistance": 3218.69, // 2 miles in meters "distanceField": "distance" } 2. Find locations within a polygon: use_mcp_tool with server_name: "mongodb", tool_name: "geo_query", arguments: { "collection": "properties", "operation": "geoWithin", "geometry": { "type": "Polygon", "coordinates": [ [[-73.958, 40.8], [-73.94, 40.79], [-73.95, 40.76], [-73.97, 40.76], [-73.958, 40.8]] ] } }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, operation: { type: 'string', description: 'Geospatial operation to perform', enum: ['near', 'geoWithin', 'geoIntersects', 'nearSphere'], }, locationField: { type: 'string', description: 'Field containing geospatial data (default: "location")', }, point: { type: 'array', description: 'Point coordinates [longitude, latitude] for near/nearSphere queries', items: { type: 'number', }, }, maxDistance: { type: 'number', description: 'Maximum distance in meters for near/nearSphere queries', }, minDistance: { type: 'number', description: 'Minimum distance in meters for near/nearSphere queries', }, geometry: { type: 'object', description: 'GeoJSON geometry for geoWithin/geoIntersects queries', }, distanceField: { type: 'string', description: 'Field to store calculated distances (for near/nearSphere queries)', }, spherical: { type: 'boolean', description: 'Calculate distances on a sphere (Earth) rather than flat plane', }, limit: { type: 'number', description: 'Maximum number of results to return', minimum: 1, maximum: 1000, }, additionalFilter: { type: 'object', description: 'Additional MongoDB query criteria to combine with geospatial query', }, }, required: ['collection', 'operation'], }, }, { name: 'text_search', description: `Perform a full-text search on a collection. Requirements: - Collection must have a text index - Only one text index per collection is allowed Features: - Supports phrases and keywords - Word stemming - Stop words removal - Text score ranking Example: use_mcp_tool with server_name: "mongodb", tool_name: "text_search", arguments: { "collection": "articles", "searchText": "mongodb database", "filter": { "published": true }, "limit": 10, "includeScore": true }`, inputSchema: { type: 'object', properties: { database: { type: 'string', description: 'Database name (optional if default database is configured)', }, collection: { type: 'string', description: 'Collection name', }, searchText: { type: 'string', description: 'Text to search for', }, filter: { type: 'object', description: 'Additional MongoDB query filter (optional)', }, limit: { type: 'number', description: 'Maximum number of results to return (optional)', minimum: 1, maximum: 1000, }, includeScore: { type: 'boolean', description: 'Include text search score in results (optional)', }, }, required: ['collection', 'searchText'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const client = await this.connect(); try { switch (request.params.name) { case 'list_databases': { const adminDb = client.db('admin'); const result = await adminDb.admin().listDatabases(); return { content: [ { type: 'text', text: JSON.stringify(result.databases, null, 2), }, ], }; } case 'list_collections': { const { database } = request.params.arguments as { database?: string }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const collections = await db.listCollections().toArray(); return { content: [ { type: 'text', text: JSON.stringify(collections, null, 2), }, ], }; } case 'get_schema': { const { database, collection, sampleSize = 100 } = request.params.arguments as { database?: string; collection: string; sampleSize?: number; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const docs = await db .collection(collection) .find() .limit(sampleSize) .toArray(); if (docs.length === 0) { return { content: [ { type: 'text', text: 'No documents found in collection', }, ], }; } const schema = await this.inferSchema(docs); return { content: [ { type: 'text', text: JSON.stringify(schema, null, 2), }, ], }; } case 'query': { const { database, collection, filter, projection, sort, limit, outputFormat = 'json', formatOptions = {} } = request.params.arguments as { database?: string; collection: string; filter: object; projection?: object; sort?: Sort; limit?: number; outputFormat?: 'json' | 'csv'; formatOptions?: any; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); let query = db.collection(collection).find(filter); if (projection) { query = query.project(projection); } if (sort) { query = query.sort(sort); } if (limit) { query = query.limit(limit); } const results = await query.toArray(); // Handle different output formats if (outputFormat.toLowerCase() === 'csv') { return { content: [ { type: 'text', text: this.documentsToCsv(results, formatOptions), }, ], }; } else { // Default JSON format const vizHint = this.generateVisualizationHint(results); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2) + (vizHint ? `\n\nVisualization Hint:\n${vizHint}` : ''), }, ], }; } } case 'aggregate': { const { database, collection, pipeline, limit } = request.params.arguments as { database?: string; collection: string; pipeline: any[]; limit?: number; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } this.validateAggregationPipeline(pipeline); const db = client.db(dbName); let aggregation = db.collection(collection).aggregate(pipeline); if (limit) { aggregation = aggregation.limit(limit); } const results = await aggregation.toArray(); const vizHint = this.generateVisualizationHint(results); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2) + (vizHint ? `\n\nVisualization Hint:\n${vizHint}` : ''), }, ], }; } case 'get_collection_stats': { const { database, collection } = request.params.arguments as { database?: string; collection: string; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const stats = await db.command({ collStats: collection }); return { content: [ { type: 'text', text: JSON.stringify(stats, null, 2), }, ], }; } case 'get_indexes': { const { database, collection } = request.params.arguments as { database?: string; collection: string; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const indexes = await db.collection(collection).indexes(); return { content: [ { type: 'text', text: JSON.stringify(indexes, null, 2), }, ], }; } case 'explain_query': { const { database, collection, filter, projection, sort } = request.params .arguments as { database?: string; collection: string; filter: object; projection?: object; sort?: Sort; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); let query = db.collection(collection).find(filter); if (projection) { query = query.project(projection); } if (sort) { query = query.sort(sort); } const explanation = await query.explain(); return { content: [ { type: 'text', text: JSON.stringify(explanation, null, 2), }, ], }; } case 'get_distinct_values': { const { database, collection, field, filter } = request.params.arguments as { database?: string; collection: string; field: string; filter?: object; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const values = await db.collection(collection).distinct(field, filter || {}); return { content: [ { type: 'text', text: JSON.stringify(values, null, 2), }, ], }; } case 'sample_data': { const { database, collection, size = 10, outputFormat = 'json', formatOptions = {} } = request.params.arguments as { database?: string; collection: string; size?: number; outputFormat?: 'json' | 'csv'; formatOptions?: any; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const sampleSize = Math.min(size, 1000); // Cap sample size for safety const results = await db.collection(collection).aggregate([ { $sample: { size: sampleSize } } ]).toArray(); // Handle different output formats if (outputFormat.toLowerCase() === 'csv') { return { content: [ { type: 'text', text: this.documentsToCsv(results, formatOptions), }, ], }; } else { // Default JSON format const vizHint = this.generateVisualizationHint(results); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2) + (vizHint ? `\n\nVisualization Hint:\n${vizHint}` : ''), }, ], }; } } case 'count_documents': { const { database, collection, filter = {} } = request.params.arguments as { database?: string; collection: string; filter?: object; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); const count = await db.collection(collection).countDocuments(filter); return { content: [ { type: 'text', text: JSON.stringify({ count }, null, 2), }, ], }; } case 'find_by_ids': { const { database, collection, ids, idField = '_id', projection } = request.params.arguments as { database?: string; collection: string; ids: (string | number)[]; idField?: string; projection?: object; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } if (!Array.isArray(ids) || ids.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'The ids parameter must be a non-empty array' ); } const db = client.db(dbName); let query = db.collection(collection).find({ [idField]: { $in: ids } }); if (projection) { query = query.project(projection); } const results = await query.toArray(); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } case 'geo_query': { const { database, collection, operation, locationField = 'location', point, maxDistance, minDistance, geometry, distanceField, spherical = true, limit = 100, additionalFilter = {} } = request.params.arguments as { database?: string; collection: string; operation: 'near' | 'geoWithin' | 'geoIntersects' | 'nearSphere'; locationField?: string; point?: number[]; maxDistance?: number; minDistance?: number; geometry?: any; distanceField?: string; spherical?: boolean; limit?: number; additionalFilter?: object; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); switch (operation) { case 'near': case 'nearSphere': { if (!Array.isArray(point) || point.length !== 2) { throw new McpError( ErrorCode.InvalidRequest, 'Point coordinates [longitude, latitude] are required for near/nearSphere queries' ); } // Validate coordinates const [longitude, latitude] = point; if (longitude < -180 || longitude > 180) { throw new McpError( ErrorCode.InvalidRequest, 'Invalid longitude: must be between -180 and 180' ); } if (latitude < -90 || latitude > 90) { throw new McpError( ErrorCode.InvalidRequest, 'Invalid latitude: must be between -90 and 90' ); } const geoNearOptions: any = { near: { type: 'Point', coordinates: point }, distanceField: distanceField || 'distance', spherical: operation === 'nearSphere' || spherical, query: additionalFilter }; if (maxDistance !== undefined) geoNearOptions.maxDistance = maxDistance; if (minDistance !== undefined) geoNearOptions.minDistance = minDistance; if (limit) geoNearOptions.limit = limit; try { // Use aggregation for geoNear const results = await db.collection(collection).aggregate([ { $geoNear: geoNearOptions } ]).toArray(); const vizHint = this.generateVisualizationHint(results); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2) + (vizHint ? `\n\nVisualization Hint:\n${vizHint}` : ''), }, ], }; } catch (error) { // Check if error is due to missing geospatial index if (error instanceof Error && (error.message.includes('2dsphere') || error.message.includes('geo'))) { throw new McpError( ErrorCode.InvalidRequest, `Geospatial index required. Create one using: db.${collection}.createIndex({ "${locationField}": "2dsphere" })` ); } throw error; } } case 'geoWithin': case 'geoIntersects': { if (!geometry || !geometry.type || !geometry.coordinates) { throw new McpError( ErrorCode.InvalidRequest, 'Valid GeoJSON geometry is required for geoWithin/geoIntersects queries' ); } const operator = operation === 'geoWithin' ? '$geoWithin' : '$geoIntersects'; const geoQuery = { [locationField]: { [operator]: { $geometry: geometry } }, ...additionalFilter }; try { const results = await db.collection(collection) .find(geoQuery) .limit(limit) .toArray(); const vizHint = this.generateVisualizationHint(results); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2) + (vizHint ? `\n\nVisualization Hint:\n${vizHint}` : ''), }, ], }; } catch (error) { // Check if error is due to missing geospatial index if (error instanceof Error && (error.message.includes('2dsphere') || error.message.includes('geo'))) { throw new McpError( ErrorCode.InvalidRequest, `Geospatial index required. Create one using: db.${collection}.createIndex({ "${locationField}": "2dsphere" })` ); } throw error; } } default: throw new McpError( ErrorCode.InvalidRequest, `Unsupported geospatial operation: ${operation}` ); } } case 'text_search': { const { database, collection, searchText, filter, limit, includeScore } = request.params .arguments as { database?: string; collection: string; searchText: string; filter?: object; limit?: number; includeScore?: boolean; }; const dbName = database || this.defaultDatabase; if (!dbName) { throw new McpError( ErrorCode.InvalidRequest, 'Database name is required when no default database is configured' ); } const db = client.db(dbName); try { const searchQuery = { $text: { $search: searchText }, ...(filter || {}), }; const projection = includeScore ? { score: { $meta: 'textScore' } } : undefined; let query = db.collection(collection).find(searchQuery); if (projection) { query = query.project(projection); } if (includeScore) { query = query.sort({ score: { $meta: 'textScore' } }); } if (limit) { query = query.limit(limit); } const results = await query.toArray(); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } catch (error) { // Check if error is due to missing text index if (error instanceof Error && error.message.includes('text index')) { throw new McpError( ErrorCode.InvalidRequest, 'No text index found on this collection. Create a text index first using db.collection.createIndex({ "field": "text" })' ); } throw error; } } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `MongoDB error: ${error instanceof Error ? error.message : String(error)}` ); } }); } /** * Starts the MCP server with stdio transport. * Handles incoming MCP requests until terminated. */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('MongoDB MCP server running on stdio'); } } const server = new MongoDBServer(); server.run().catch(console.error);