mcp-mongo-server 1.1.2 → 1.3.0
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.
- package/README.md +281 -117
- package/build/index.js +42 -1022
- package/build/mongo.js +33 -0
- package/build/schemas/call.js +591 -0
- package/build/schemas/completion.js +112 -0
- package/build/schemas/ping.js +20 -0
- package/build/schemas/prompts.js +71 -0
- package/build/schemas/resource.js +213 -0
- package/build/schemas/templates.js +39 -0
- package/build/schemas/tools.js +281 -0
- package/build/server.js +65 -0
- package/package.json +1 -1
- package/build/types.js +0 -169
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles completion requests from the Model Context Protocol
|
|
3
|
+
* @param options The request options
|
|
4
|
+
* @returns Completion result with matching values
|
|
5
|
+
*/
|
|
6
|
+
export async function handleCompletionRequest({ request, client, db, isReadOnlyMode, }) {
|
|
7
|
+
const { ref, argument } = request.params;
|
|
8
|
+
// Handle completions for prompts
|
|
9
|
+
if (ref.type === "ref/prompt") {
|
|
10
|
+
return handlePromptCompletion(client, db, isReadOnlyMode, ref.name, argument);
|
|
11
|
+
}
|
|
12
|
+
// Handle completions for resources
|
|
13
|
+
if (ref.type === "ref/resource") {
|
|
14
|
+
return handleResourceCompletion(client, db, isReadOnlyMode, ref.uri, argument);
|
|
15
|
+
}
|
|
16
|
+
// Default empty response if reference type is not supported
|
|
17
|
+
return emptyCompletionResult();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Handle completion requests for prompts
|
|
21
|
+
* @param client MongoDB client
|
|
22
|
+
* @param db MongoDB database
|
|
23
|
+
* @param isReadOnlyMode Whether the database is in read-only mode
|
|
24
|
+
* @param promptName Name of the prompt
|
|
25
|
+
* @param argument Argument being completed
|
|
26
|
+
* @returns Completion result
|
|
27
|
+
*/
|
|
28
|
+
async function handlePromptCompletion(client, db, isReadOnlyMode, promptName, argument) {
|
|
29
|
+
if (!promptName) {
|
|
30
|
+
return emptyCompletionResult();
|
|
31
|
+
}
|
|
32
|
+
// Handle collection name completions
|
|
33
|
+
if (argument.name === "collection") {
|
|
34
|
+
return await completeCollectionNames(argument.value, db, isReadOnlyMode);
|
|
35
|
+
}
|
|
36
|
+
// Add other prompt completions here as needed
|
|
37
|
+
return emptyCompletionResult();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Handle completion requests for resources
|
|
41
|
+
* @param client MongoDB client
|
|
42
|
+
* @param db MongoDB database
|
|
43
|
+
* @param isReadOnlyMode Whether the database is in read-only mode
|
|
44
|
+
* @param uri Resource URI
|
|
45
|
+
* @param argument Argument being completed
|
|
46
|
+
* @returns Completion result
|
|
47
|
+
*/
|
|
48
|
+
async function handleResourceCompletion(client, db, isReadOnlyMode, promptName, argument) {
|
|
49
|
+
if (!promptName) {
|
|
50
|
+
return emptyCompletionResult();
|
|
51
|
+
}
|
|
52
|
+
// Handle collection name completions
|
|
53
|
+
if (argument.name === "collection") {
|
|
54
|
+
return await completeCollectionNames(argument.value, db, isReadOnlyMode);
|
|
55
|
+
}
|
|
56
|
+
// Add other prompt completions here as needed
|
|
57
|
+
return emptyCompletionResult();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get collection name completions for a partial value
|
|
61
|
+
* @param partialValue Partial collection name
|
|
62
|
+
* @param db MongoDB database
|
|
63
|
+
* @param isReadOnlyMode Whether the database is in read-only mode
|
|
64
|
+
* @returns Completion result with matching collection names
|
|
65
|
+
*/
|
|
66
|
+
async function completeCollectionNames(partialValue, db, isReadOnlyMode) {
|
|
67
|
+
try {
|
|
68
|
+
console.warn(`Completing collection names with partial value: ${partialValue}`);
|
|
69
|
+
// Get list of collections
|
|
70
|
+
const collections = await db.listCollections().toArray();
|
|
71
|
+
// Filter collections by partial value (case insensitive)
|
|
72
|
+
const matchingCollections = collections
|
|
73
|
+
.map((c) => c.name)
|
|
74
|
+
.filter((name) =>
|
|
75
|
+
// Filter out system collections
|
|
76
|
+
!name.startsWith("system.") &&
|
|
77
|
+
// Match partial value
|
|
78
|
+
name.toLowerCase().includes(partialValue.toLowerCase()))
|
|
79
|
+
// Sort alphabetically
|
|
80
|
+
.sort();
|
|
81
|
+
console.warn(`Found ${matchingCollections.length} matching collections`);
|
|
82
|
+
// Limit to 100 items as per spec
|
|
83
|
+
const MAX_ITEMS = 100;
|
|
84
|
+
const limitedResults = matchingCollections.slice(0, MAX_ITEMS);
|
|
85
|
+
const hasMore = matchingCollections.length > MAX_ITEMS;
|
|
86
|
+
return {
|
|
87
|
+
completion: {
|
|
88
|
+
values: limitedResults,
|
|
89
|
+
total: matchingCollections.length,
|
|
90
|
+
hasMore,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.error("Error completing collection names:", error);
|
|
96
|
+
// Return empty result on error
|
|
97
|
+
return emptyCompletionResult();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Create an empty completion result
|
|
102
|
+
* @returns Empty completion result object
|
|
103
|
+
*/
|
|
104
|
+
function emptyCompletionResult() {
|
|
105
|
+
return {
|
|
106
|
+
completion: {
|
|
107
|
+
values: [],
|
|
108
|
+
total: 0,
|
|
109
|
+
hasMore: false,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export async function handlePingRequest({ request, client, db, isReadOnlyMode, }) {
|
|
2
|
+
try {
|
|
3
|
+
// Check MongoDB connection
|
|
4
|
+
if (!client) {
|
|
5
|
+
throw new Error("MongoDB connection is not available");
|
|
6
|
+
}
|
|
7
|
+
// Ping MongoDB to verify connection
|
|
8
|
+
const pong = await db.command({ ping: 1 });
|
|
9
|
+
if (pong.ok !== 1) {
|
|
10
|
+
throw new Error(`MongoDB ping failed: ${pong.errmsg}`);
|
|
11
|
+
}
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof Error) {
|
|
16
|
+
throw new Error(`MongoDB ping failed: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
throw new Error("MongoDB ping failed: Unknown error");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export async function handleListPromptsRequest({ request, client, db, isReadOnlyMode, }) {
|
|
2
|
+
return {
|
|
3
|
+
prompts: [
|
|
4
|
+
{
|
|
5
|
+
name: "analyze_collection",
|
|
6
|
+
description: "Analyze a MongoDB collection structure and contents",
|
|
7
|
+
arguments: [
|
|
8
|
+
{
|
|
9
|
+
name: "collection",
|
|
10
|
+
description: "Name of the collection to analyze",
|
|
11
|
+
required: true,
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function handleGetPromptRequest({ request, client, db, isReadOnlyMode, }) {
|
|
19
|
+
const { name, arguments: args = {} } = request.params;
|
|
20
|
+
if (name !== "analyze_collection") {
|
|
21
|
+
throw new Error("Unknown prompt");
|
|
22
|
+
}
|
|
23
|
+
const collectionName = args.collection;
|
|
24
|
+
if (!collectionName) {
|
|
25
|
+
throw new Error("Collection name is required");
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const collection = db.collection(collectionName);
|
|
29
|
+
if (collection.collectionName.startsWith("system.")) {
|
|
30
|
+
throw new Error("Access to system collections is not allowed");
|
|
31
|
+
}
|
|
32
|
+
const schemaSample = await collection.findOne({});
|
|
33
|
+
const stats = await collection
|
|
34
|
+
.aggregate([{ $collStats: { count: {} } }])
|
|
35
|
+
.toArray();
|
|
36
|
+
const sampleDocs = await collection.find({}).limit(5).toArray();
|
|
37
|
+
const documentCount = stats[0]?.count ?? "unknown";
|
|
38
|
+
return {
|
|
39
|
+
messages: [
|
|
40
|
+
{
|
|
41
|
+
role: "user",
|
|
42
|
+
content: {
|
|
43
|
+
type: "text",
|
|
44
|
+
text: `Please analyze the following MongoDB collection:
|
|
45
|
+
Collection: ${collectionName}
|
|
46
|
+
|
|
47
|
+
Schema:
|
|
48
|
+
${JSON.stringify(schemaSample, null, 2)}
|
|
49
|
+
|
|
50
|
+
Stats:
|
|
51
|
+
Document count: ${documentCount}
|
|
52
|
+
|
|
53
|
+
Sample documents:
|
|
54
|
+
${JSON.stringify(sampleDocs, null, 2)}`,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
role: "user",
|
|
59
|
+
content: {
|
|
60
|
+
type: "text",
|
|
61
|
+
text: "Provide insights about the collection's structure, data types, and basic statistics.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
69
|
+
throw new Error(`Failed to analyze collection ${collectionName}: ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ObjectId } from "mongodb";
|
|
2
|
+
/**
|
|
3
|
+
* Detects the MongoDB-specific type of a value
|
|
4
|
+
* @param value The value to detect the type of
|
|
5
|
+
* @returns A string representing the detected type
|
|
6
|
+
*/
|
|
7
|
+
function detectMongoType(value) {
|
|
8
|
+
if (value === null)
|
|
9
|
+
return 'null';
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return 'undefined';
|
|
12
|
+
if (value instanceof ObjectId)
|
|
13
|
+
return 'ObjectId';
|
|
14
|
+
if (value instanceof Date)
|
|
15
|
+
return 'Date';
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
if (value.length === 0)
|
|
18
|
+
return 'Array';
|
|
19
|
+
// Check if array has consistent types
|
|
20
|
+
const elementTypes = new Set(value.map(item => detectMongoType(item)));
|
|
21
|
+
if (elementTypes.size === 1) {
|
|
22
|
+
return `Array<${Array.from(elementTypes)[0]}>`;
|
|
23
|
+
}
|
|
24
|
+
return 'Array<mixed>';
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'object') {
|
|
27
|
+
// Handle nested documents
|
|
28
|
+
return 'Document';
|
|
29
|
+
}
|
|
30
|
+
return typeof value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Helper function to infer a schema from multiple documents
|
|
34
|
+
* @param documents Array of sample documents from the collection
|
|
35
|
+
* @returns Inferred schema with field names and types
|
|
36
|
+
*/
|
|
37
|
+
function inferSchemaFromSamples(documents) {
|
|
38
|
+
if (!documents || documents.length === 0) {
|
|
39
|
+
return { fields: [] };
|
|
40
|
+
}
|
|
41
|
+
// Use a Map to store field information, with the key being the field name
|
|
42
|
+
const fieldMap = new Map();
|
|
43
|
+
// Process each document to collect field information
|
|
44
|
+
for (const doc of documents) {
|
|
45
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
46
|
+
if (!fieldMap.has(key)) {
|
|
47
|
+
// Initialize field info if we haven't seen this field before
|
|
48
|
+
fieldMap.set(key, {
|
|
49
|
+
name: key,
|
|
50
|
+
types: new Set([detectMongoType(value)]),
|
|
51
|
+
nullable: false,
|
|
52
|
+
// Store sample values for complex types
|
|
53
|
+
samples: [value],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Update existing field info
|
|
58
|
+
const fieldInfo = fieldMap.get(key);
|
|
59
|
+
fieldInfo.types.add(detectMongoType(value));
|
|
60
|
+
// Store up to 3 different sample values
|
|
61
|
+
if (fieldInfo.samples.length < 3 &&
|
|
62
|
+
!fieldInfo.samples.some((sample) => JSON.stringify(sample) === JSON.stringify(value))) {
|
|
63
|
+
fieldInfo.samples.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Check for nullable fields by seeing which fields are missing in some documents
|
|
69
|
+
for (const doc of documents) {
|
|
70
|
+
for (const [key] of fieldMap.entries()) {
|
|
71
|
+
if (!(key in doc)) {
|
|
72
|
+
const fieldInfo = fieldMap.get(key);
|
|
73
|
+
fieldInfo.nullable = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Process nested document schemas
|
|
78
|
+
for (const [key, fieldInfo] of fieldMap.entries()) {
|
|
79
|
+
if (fieldInfo.types.has('Document')) {
|
|
80
|
+
// Extract nested documents for this field
|
|
81
|
+
const nestedDocs = documents
|
|
82
|
+
.filter(doc => doc[key] && typeof doc[key] === 'object' && !Array.isArray(doc[key]))
|
|
83
|
+
.map(doc => doc[key]);
|
|
84
|
+
if (nestedDocs.length > 0) {
|
|
85
|
+
// Recursively infer schema for nested documents
|
|
86
|
+
fieldInfo.nestedSchema = inferSchemaFromSamples(nestedDocs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Convert the Map to an array of field objects with additional info
|
|
91
|
+
const fields = Array.from(fieldMap.values()).map(fieldInfo => {
|
|
92
|
+
const result = {
|
|
93
|
+
name: fieldInfo.name,
|
|
94
|
+
types: Array.from(fieldInfo.types),
|
|
95
|
+
nullable: fieldInfo.nullable,
|
|
96
|
+
prevalence: Math.round((documents.filter(doc => fieldInfo.name in doc).length / documents.length) * 100) + '%',
|
|
97
|
+
examples: [],
|
|
98
|
+
};
|
|
99
|
+
// Include nested schema if available
|
|
100
|
+
if (fieldInfo.nestedSchema) {
|
|
101
|
+
result.nestedSchema = fieldInfo.nestedSchema;
|
|
102
|
+
}
|
|
103
|
+
// Include simplified sample values
|
|
104
|
+
const sampleValues = fieldInfo.samples.map((sample) => {
|
|
105
|
+
if (sample instanceof ObjectId)
|
|
106
|
+
return sample.toString();
|
|
107
|
+
if (sample instanceof Date)
|
|
108
|
+
return sample.toISOString();
|
|
109
|
+
if (typeof sample === 'object') {
|
|
110
|
+
// For objects/arrays, just indicate type rather than full structure
|
|
111
|
+
return Array.isArray(sample) ? '[...]' : '{...}';
|
|
112
|
+
}
|
|
113
|
+
return sample;
|
|
114
|
+
});
|
|
115
|
+
result.examples = sampleValues;
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
118
|
+
return { fields };
|
|
119
|
+
}
|
|
120
|
+
export async function handleReadResourceRequest({ request, client, db, isReadOnlyMode, }) {
|
|
121
|
+
const url = new URL(request.params.uri);
|
|
122
|
+
const collectionName = url.pathname.replace(/^\//, "");
|
|
123
|
+
try {
|
|
124
|
+
const collection = db.collection(collectionName);
|
|
125
|
+
// Set sample size for schema inference
|
|
126
|
+
const sampleSize = 100;
|
|
127
|
+
let sampleDocuments = [];
|
|
128
|
+
try {
|
|
129
|
+
// First try using MongoDB's $sample aggregation to get a diverse set of documents
|
|
130
|
+
sampleDocuments = await collection
|
|
131
|
+
.aggregate([{ $sample: { size: sampleSize } }])
|
|
132
|
+
.toArray();
|
|
133
|
+
}
|
|
134
|
+
catch (sampleError) {
|
|
135
|
+
// Fallback to sequential scan if $sample is not available
|
|
136
|
+
console.warn(`$sample aggregation failed for ${collectionName}, falling back to sequential scan: ${sampleError}`);
|
|
137
|
+
sampleDocuments = await collection
|
|
138
|
+
.find({})
|
|
139
|
+
.limit(sampleSize)
|
|
140
|
+
.toArray();
|
|
141
|
+
}
|
|
142
|
+
// Get indexes for the collection
|
|
143
|
+
const indexes = await collection.indexes();
|
|
144
|
+
// Infer schema from samples
|
|
145
|
+
const inferredSchema = inferSchemaFromSamples(sampleDocuments);
|
|
146
|
+
// Get document count with timeout protection
|
|
147
|
+
let documentCount = null;
|
|
148
|
+
try {
|
|
149
|
+
// Set a timeout for the count operation
|
|
150
|
+
documentCount = await Promise.race([
|
|
151
|
+
collection.countDocuments(),
|
|
152
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Count operation timed out')), 5000))
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
catch (countError) {
|
|
156
|
+
console.warn(`Count operation failed or timed out for ${collectionName}: ${countError}`);
|
|
157
|
+
// Estimate count based on sample size and collection stats
|
|
158
|
+
try {
|
|
159
|
+
const stats = await db.command({ collStats: collectionName });
|
|
160
|
+
documentCount = stats.count;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
documentCount = 'unknown (count operation timed out)';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const schema = {
|
|
167
|
+
type: "collection",
|
|
168
|
+
name: collectionName,
|
|
169
|
+
fields: inferredSchema.fields,
|
|
170
|
+
indexes: indexes.map((idx) => ({
|
|
171
|
+
name: idx.name,
|
|
172
|
+
keys: idx.key,
|
|
173
|
+
})),
|
|
174
|
+
documentCount: documentCount,
|
|
175
|
+
sampleSize: sampleDocuments.length,
|
|
176
|
+
lastUpdated: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
contents: [
|
|
180
|
+
{
|
|
181
|
+
uri: request.params.uri,
|
|
182
|
+
mimeType: "application/json",
|
|
183
|
+
text: JSON.stringify(schema, null, 2),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof Error) {
|
|
190
|
+
throw new Error(`Failed to read collection ${collectionName}: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`Failed to read collection ${collectionName}: Unknown error`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function handleListResourcesRequest({ request, client, db, isReadOnlyMode, }) {
|
|
196
|
+
try {
|
|
197
|
+
const collections = await db.listCollections().toArray();
|
|
198
|
+
return {
|
|
199
|
+
resources: collections.map((collection) => ({
|
|
200
|
+
uri: `mongodb:///${collection.name}`,
|
|
201
|
+
mimeType: "application/json",
|
|
202
|
+
name: collection.name,
|
|
203
|
+
description: `MongoDB collection: ${collection.name}`,
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error instanceof Error) {
|
|
209
|
+
throw new Error(`Failed to list collections: ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Failed to list collections: Unknown error");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export async function handleListResourceTemplatesRequest({ request, client, db, isReadOnlyMode, }) {
|
|
2
|
+
return {
|
|
3
|
+
resourceTemplates: [
|
|
4
|
+
{
|
|
5
|
+
name: "mongodb_query",
|
|
6
|
+
description: "Template for constructing MongoDB queries",
|
|
7
|
+
uriTemplate: "mongodb:///{collection}",
|
|
8
|
+
text: `To query MongoDB collections, you can use these operators:
|
|
9
|
+
|
|
10
|
+
Filter operators:
|
|
11
|
+
- $eq: Matches values equal to a specified value
|
|
12
|
+
- $gt/$gte: Matches values greater than (or equal to) a specified value
|
|
13
|
+
- $lt/$lte: Matches values less than (or equal to) a specified value
|
|
14
|
+
- $in: Matches any of the values in an array
|
|
15
|
+
- $nin: Matches none of the values in an array
|
|
16
|
+
- $ne: Matches values not equal to a specified value
|
|
17
|
+
- $exists: Matches documents that have the specified field
|
|
18
|
+
|
|
19
|
+
Example queries:
|
|
20
|
+
1. Find documents where age > 21:
|
|
21
|
+
{ "age": { "$gt": 21 } }
|
|
22
|
+
|
|
23
|
+
2. Find documents with specific status:
|
|
24
|
+
{ "status": { "$in": ["active", "pending"] } }
|
|
25
|
+
|
|
26
|
+
3. Find documents with existing email:
|
|
27
|
+
{ "email": { "$exists": true } }
|
|
28
|
+
|
|
29
|
+
4. Find documents with dates:
|
|
30
|
+
// Using ISO date string format
|
|
31
|
+
{ "createdAt": { "$gt": "2023-01-01T00:00:00Z" } }
|
|
32
|
+
// Using ISODate syntax
|
|
33
|
+
{ "createdAt": { "$gt": ISODate("2023-01-01T00:00:00Z") } }
|
|
34
|
+
|
|
35
|
+
Use these patterns to construct MongoDB queries.`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|