@mongoosejs/studio 0.5.14 → 0.6.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/AGENTS.md +69 -2
- package/README.md +24 -0
- package/backend/actions/ChatMessage/executeScript.js +14 -6
- package/backend/actions/ChatThread/createChatMessage.js +7 -4
- package/backend/actions/ChatThread/getChatThread.js +7 -4
- package/backend/actions/ChatThread/listChatThreads.js +5 -2
- package/backend/actions/ChatThread/shareChatThread.js +5 -2
- package/backend/actions/ChatThread/streamChatMessage.js +7 -4
- package/backend/actions/ChatThread/toggleAgentMode.js +5 -2
- package/backend/actions/Dashboard/createDashboard.js +3 -0
- package/backend/actions/Dashboard/deleteDashboard.js +9 -3
- package/backend/actions/Dashboard/getDashboard.js +23 -11
- package/backend/actions/Dashboard/getDashboards.js +8 -2
- package/backend/actions/Dashboard/updateDashboard.js +8 -2
- package/backend/actions/Model/addField.js +8 -2
- package/backend/actions/Model/analyzeSchema.js +15 -5
- package/backend/actions/Model/createChatMessage.js +3 -0
- package/backend/actions/Model/createDocument.js +3 -0
- package/backend/actions/Model/deleteDocument.js +8 -2
- package/backend/actions/Model/deleteDocuments.js +8 -2
- package/backend/actions/Model/dropCollection.js +3 -0
- package/backend/actions/Model/dropIndex.js +3 -0
- package/backend/actions/Model/executeDocumentScript.js +14 -3
- package/backend/actions/Model/exportQueryResults.js +12 -2
- package/backend/actions/Model/getCollectionInfo.js +14 -4
- package/backend/actions/Model/getDocument.js +12 -2
- package/backend/actions/Model/getDocuments.js +13 -3
- package/backend/actions/Model/getDocumentsStream.js +13 -3
- package/backend/actions/Model/getEstimatedDocumentCounts.js +12 -2
- package/backend/actions/Model/getIndexes.js +3 -0
- package/backend/actions/Model/getSuggestedProjection.js +3 -0
- package/backend/actions/Model/listModels.js +3 -0
- package/backend/actions/Model/streamChatMessage.js +3 -0
- package/backend/actions/Model/streamDocumentChanges.js +3 -0
- package/backend/actions/Model/updateDocument.js +8 -2
- package/backend/actions/Model/updateDocuments.js +8 -2
- package/backend/actions/Model/validateDocument.js +12 -2
- package/backend/actions/Script/createScript.js +36 -0
- package/backend/actions/Script/executeScript.js +89 -0
- package/backend/actions/Script/getScript.js +43 -0
- package/backend/actions/Script/index.js +5 -0
- package/backend/actions/Task/cancelTask.js +5 -2
- package/backend/actions/Task/getTaskOverview.js +6 -3
- package/backend/actions/Task/getTasks.js +6 -3
- package/backend/actions/Task/getTasksOverTime.js +6 -3
- package/backend/actions/Task/rescheduleTask.js +5 -2
- package/backend/actions/Task/runTask.js +5 -2
- package/backend/actions/index.js +1 -0
- package/backend/authorize.js +6 -1
- package/backend/db/scriptSchema.js +29 -0
- package/backend/helpers/operationOptions.js +26 -0
- package/backend/helpers/readOperationOptions.js +31 -0
- package/backend/index.js +2 -0
- package/backend/integrations/mothership.js +28 -0
- package/backend/mcp.js +140 -0
- package/backend/netlify.js +5 -3
- package/backend/next.js +5 -3
- package/backend/sandbox/createSandbox.js +2 -2
- package/backend/sandbox/createScriptDb.js +40 -23
- package/backend/util/archetypeToZodSchema.js +74 -0
- package/docs/mcp-oauth.md +106 -0
- package/eslint.config.js +1 -0
- package/eval/agent/run.js +1 -1
- package/express.js +183 -41
- package/frontend/index.js +3 -0
- package/frontend/public/app.js +441 -48
- package/frontend/public/tw.css +8 -0
- package/frontend/src/api.js +22 -0
- package/frontend/src/index.js +9 -1
- package/frontend/src/mcp/mcp.html +55 -0
- package/frontend/src/mcp/mcp.js +27 -0
- package/frontend/src/navbar/navbar.html +8 -0
- package/frontend/src/navbar/navbar.js +3 -0
- package/frontend/src/routes.js +16 -0
- package/frontend/src/script/script.html +105 -0
- package/frontend/src/script/script.js +98 -0
- package/index.d.ts +10 -1
- package/nest.d.ts +3 -0
- package/package.json +5 -3
- package/vercel.d.ts +2 -0
- package/CLAUDE.md +0 -7
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Archetype = require('archetype');
|
|
4
|
+
const mongoose = require('mongoose');
|
|
5
|
+
|
|
6
|
+
const authorize = require('../../authorize');
|
|
7
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
8
|
+
|
|
9
|
+
const GetScriptParams = new Archetype({
|
|
10
|
+
initiatedById: {
|
|
11
|
+
$type: mongoose.Types.ObjectId
|
|
12
|
+
},
|
|
13
|
+
scriptId: {
|
|
14
|
+
$type: mongoose.Types.ObjectId,
|
|
15
|
+
$required: true
|
|
16
|
+
},
|
|
17
|
+
roles: {
|
|
18
|
+
$type: ['string']
|
|
19
|
+
},
|
|
20
|
+
maxTimeMS: {
|
|
21
|
+
$type: 'number'
|
|
22
|
+
}
|
|
23
|
+
}).compile('GetScriptParams');
|
|
24
|
+
|
|
25
|
+
module.exports = ({ studioConnection, options }) => async function getScript(params) {
|
|
26
|
+
const { initiatedById, scriptId, roles } = new GetScriptParams(params);
|
|
27
|
+
|
|
28
|
+
await authorize('Script.getScript', roles);
|
|
29
|
+
|
|
30
|
+
const Script = studioConnection.model('__Studio_Script');
|
|
31
|
+
const script = await Script.findById(scriptId).setOptions(operationOptions(options, params));
|
|
32
|
+
if (script == null) {
|
|
33
|
+
throw new Error('Script not found');
|
|
34
|
+
}
|
|
35
|
+
if (initiatedById && script.userId?.toString() !== initiatedById.toString()) {
|
|
36
|
+
throw new Error('Unauthorized');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { script };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
module.exports.paramsType = GetScriptParams;
|
|
43
|
+
module.exports.tags = ['readOnly'];
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
|
-
const
|
|
5
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
6
6
|
|
|
7
7
|
const CancelTaskParams = new Archetype({
|
|
8
8
|
taskId: {
|
|
9
9
|
$type: mongoose.Types.ObjectId,
|
|
10
10
|
$required: true
|
|
11
|
+
},
|
|
12
|
+
maxTimeMS: {
|
|
13
|
+
$type: 'number'
|
|
11
14
|
}
|
|
12
15
|
}).compile('CancelTaskParams');
|
|
13
16
|
|
|
@@ -17,7 +20,7 @@ module.exports = ({ db, options }) => async function cancelTask(params) {
|
|
|
17
20
|
const { Task } = db.models;
|
|
18
21
|
|
|
19
22
|
const task = await Task.findOne({ _id: taskId }).
|
|
20
|
-
setOptions(
|
|
23
|
+
setOptions(operationOptions(options, params)).
|
|
21
24
|
orFail();
|
|
22
25
|
|
|
23
26
|
const cancelledTask = await Task.cancelTask({ _id: taskId });
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
4
|
const escape = require('regexp.escape');
|
|
5
|
-
const
|
|
5
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
6
6
|
|
|
7
7
|
const GetTaskOverviewParams = new Archetype({
|
|
8
8
|
start: { $type: Date },
|
|
9
9
|
end: { $type: Date },
|
|
10
10
|
status: { $type: 'string' },
|
|
11
|
-
name: { $type: 'string' }
|
|
11
|
+
name: { $type: 'string' },
|
|
12
|
+
maxTimeMS: {
|
|
13
|
+
$type: 'number'
|
|
14
|
+
}
|
|
12
15
|
}).compile('GetTaskOverviewParams');
|
|
13
16
|
|
|
14
17
|
/** Statuses shown on the Task overview page. */
|
|
@@ -94,7 +97,7 @@ module.exports = ({ db, options }) => async function getTaskOverview(params) {
|
|
|
94
97
|
}
|
|
95
98
|
];
|
|
96
99
|
|
|
97
|
-
const [result] = await Task.aggregate(pipeline).option(
|
|
100
|
+
const [result] = await Task.aggregate(pipeline).option(operationOptions(options, params));
|
|
98
101
|
|
|
99
102
|
return {
|
|
100
103
|
statusCounts: result.statusCounts?.[0] ?? {},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
4
|
const escape = require('regexp.escape');
|
|
5
|
-
const
|
|
5
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
6
6
|
|
|
7
7
|
const GetTasksParams = new Archetype({
|
|
8
8
|
start: { $type: Date },
|
|
@@ -10,7 +10,10 @@ const GetTasksParams = new Archetype({
|
|
|
10
10
|
status: { $type: 'string' },
|
|
11
11
|
name: { $type: 'string' },
|
|
12
12
|
skip: { $type: 'number', $default: 0 },
|
|
13
|
-
limit: { $type: 'number', $default: 100 }
|
|
13
|
+
limit: { $type: 'number', $default: 100 },
|
|
14
|
+
maxTimeMS: {
|
|
15
|
+
$type: 'number'
|
|
16
|
+
}
|
|
14
17
|
}).compile('GetTasksParams');
|
|
15
18
|
|
|
16
19
|
const ALL_STATUSES = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
|
|
@@ -97,7 +100,7 @@ module.exports = ({ db, options }) => async function getTasks(params) {
|
|
|
97
100
|
}
|
|
98
101
|
];
|
|
99
102
|
|
|
100
|
-
const [result] = await Task.aggregate(pipeline).option(
|
|
103
|
+
const [result] = await Task.aggregate(pipeline).option(operationOptions(options, params));
|
|
101
104
|
const tasks = result.tasks || [];
|
|
102
105
|
const numDocs = (result.count && result.count[0] && result.count[0].total) || 0;
|
|
103
106
|
const statusCounts = result.statusCounts?.[0] ?? {};
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
|
-
const
|
|
4
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
5
5
|
|
|
6
6
|
const GetTasksOverTimeParams = new Archetype({
|
|
7
7
|
start: { $type: Date },
|
|
8
8
|
end: { $type: Date },
|
|
9
|
-
bucketSizeMs: { $type: 'number' }
|
|
9
|
+
bucketSizeMs: { $type: 'number' },
|
|
10
|
+
maxTimeMS: {
|
|
11
|
+
$type: 'number'
|
|
12
|
+
}
|
|
10
13
|
}).compile('GetTasksOverTimeParams');
|
|
11
14
|
|
|
12
15
|
const TRACKED_STATUSES = ['succeeded', 'failed', 'cancelled'];
|
|
@@ -55,7 +58,7 @@ module.exports = ({ db, options }) => async function getTasksOverTime(params) {
|
|
|
55
58
|
{ $sort: { _id: 1 } }
|
|
56
59
|
];
|
|
57
60
|
|
|
58
|
-
const results = await Task.aggregate(pipeline).option(
|
|
61
|
+
const results = await Task.aggregate(pipeline).option(operationOptions(options, params));
|
|
59
62
|
|
|
60
63
|
return results.map(r => {
|
|
61
64
|
const bucket = { timestamp: r._id, succeeded: 0, failed: 0, cancelled: 0 };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
|
-
const
|
|
5
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
6
6
|
|
|
7
7
|
const RescheduleTaskParams = new Archetype({
|
|
8
8
|
taskId: {
|
|
@@ -12,6 +12,9 @@ const RescheduleTaskParams = new Archetype({
|
|
|
12
12
|
scheduledAt: {
|
|
13
13
|
$type: Date,
|
|
14
14
|
$required: true
|
|
15
|
+
},
|
|
16
|
+
maxTimeMS: {
|
|
17
|
+
$type: 'number'
|
|
15
18
|
}
|
|
16
19
|
}).compile('RescheduleTaskParams');
|
|
17
20
|
|
|
@@ -21,7 +24,7 @@ module.exports = ({ db, options }) => async function rescheduleTask(params) {
|
|
|
21
24
|
const { Task } = db.models;
|
|
22
25
|
|
|
23
26
|
const task = await Task.findOne({ _id: taskId }).
|
|
24
|
-
setOptions(
|
|
27
|
+
setOptions(operationOptions(options, params)).
|
|
25
28
|
orFail();
|
|
26
29
|
|
|
27
30
|
if (scheduledAt < Date.now()) {
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const Archetype = require('archetype');
|
|
4
4
|
const mongoose = require('mongoose');
|
|
5
|
-
const
|
|
5
|
+
const operationOptions = require('../../helpers/operationOptions');
|
|
6
6
|
|
|
7
7
|
const RunTaskParams = new Archetype({
|
|
8
8
|
taskId: {
|
|
9
9
|
$type: mongoose.Types.ObjectId,
|
|
10
10
|
$required: true
|
|
11
|
+
},
|
|
12
|
+
maxTimeMS: {
|
|
13
|
+
$type: 'number'
|
|
11
14
|
}
|
|
12
15
|
}).compile('RunTaskParams');
|
|
13
16
|
|
|
@@ -17,7 +20,7 @@ module.exports = ({ db, options }) => async function runTask(params) {
|
|
|
17
20
|
const { Task } = db.models;
|
|
18
21
|
|
|
19
22
|
const task = await Task.findOne({ _id: taskId }).
|
|
20
|
-
setOptions(
|
|
23
|
+
setOptions(operationOptions(options, params)).
|
|
21
24
|
orFail();
|
|
22
25
|
|
|
23
26
|
const executedTask = await Task.execute(task);
|
package/backend/actions/index.js
CHANGED
|
@@ -5,5 +5,6 @@ exports.ChatThread = require('./ChatThread');
|
|
|
5
5
|
exports.Dashboard = require('./Dashboard');
|
|
6
6
|
exports.getCapabilities = require('./getCapabilities');
|
|
7
7
|
exports.Model = require('./Model');
|
|
8
|
+
exports.Script = require('./Script');
|
|
8
9
|
exports.status = require('./status');
|
|
9
10
|
exports.Task = require('./Task');
|
package/backend/authorize.js
CHANGED
|
@@ -14,6 +14,7 @@ const actionsToRequiredRoles = {
|
|
|
14
14
|
'Dashboard.getDashboards': ['owner', 'admin', 'member', 'readonly', 'dashboards'],
|
|
15
15
|
'Dashboard.updateDashboard': ['owner', 'admin', 'member'],
|
|
16
16
|
'Model.createDocument': ['owner', 'admin', 'member'],
|
|
17
|
+
'Model.createChatMessage': ['owner', 'admin', 'member'],
|
|
17
18
|
'Model.analyzeSchema': ['owner', 'admin', 'member', 'readonly'],
|
|
18
19
|
'Model.updateDocument': ['owner', 'admin', 'member'],
|
|
19
20
|
'Model.validateDocument': ['owner', 'admin', 'member', 'readonly'],
|
|
@@ -24,6 +25,7 @@ const actionsToRequiredRoles = {
|
|
|
24
25
|
'Model.executeDocumentScript': ['owner', 'admin', 'member'],
|
|
25
26
|
'Model.exportQueryResults': ['owner', 'admin', 'member', 'readonly'],
|
|
26
27
|
'Model.getDocument': ['owner', 'admin', 'member', 'readonly'],
|
|
28
|
+
'Model.getCollectionInfo': ['owner', 'admin', 'member', 'readonly'],
|
|
27
29
|
'Model.getDocuments': ['owner', 'admin', 'member', 'readonly'],
|
|
28
30
|
'Model.getDocumentsStream': ['owner', 'admin', 'member', 'readonly'],
|
|
29
31
|
'Model.getSuggestedProjection': ['owner', 'admin', 'member', 'readonly'],
|
|
@@ -32,7 +34,10 @@ const actionsToRequiredRoles = {
|
|
|
32
34
|
'Model.listModels': ['owner', 'admin', 'member', 'readonly'],
|
|
33
35
|
'Model.streamDocumentChanges': ['owner', 'admin', 'member', 'readonly'],
|
|
34
36
|
'Model.streamChatMessage': ['owner', 'admin', 'member', 'readonly'],
|
|
35
|
-
'Model.updateDocuments': ['owner', 'admin', 'member']
|
|
37
|
+
'Model.updateDocuments': ['owner', 'admin', 'member'],
|
|
38
|
+
'Script.createScript': ['owner', 'admin', 'member', 'readonly'],
|
|
39
|
+
'Script.executeScript': ['owner', 'admin', 'member', 'readonly'],
|
|
40
|
+
'Script.getScript': ['owner', 'admin', 'member', 'readonly']
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
module.exports = function authorize(action, roles) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const mongoose = require('mongoose');
|
|
4
|
+
|
|
5
|
+
const scriptSchema = new mongoose.Schema({
|
|
6
|
+
script: {
|
|
7
|
+
type: String,
|
|
8
|
+
required: true
|
|
9
|
+
},
|
|
10
|
+
executionResult: new mongoose.Schema({
|
|
11
|
+
output: mongoose.Schema.Types.Mixed,
|
|
12
|
+
logs: String,
|
|
13
|
+
error: String
|
|
14
|
+
}, { _id: false }),
|
|
15
|
+
userId: {
|
|
16
|
+
type: mongoose.Schema.Types.ObjectId
|
|
17
|
+
},
|
|
18
|
+
dryRun: {
|
|
19
|
+
type: Boolean
|
|
20
|
+
},
|
|
21
|
+
executionStartedAt: {
|
|
22
|
+
type: Date
|
|
23
|
+
},
|
|
24
|
+
executionFinishedAt: {
|
|
25
|
+
type: Date
|
|
26
|
+
}
|
|
27
|
+
}, { timestamps: true });
|
|
28
|
+
|
|
29
|
+
module.exports = scriptSchema;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const omitNullish = require('./omitNullish');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options to apply to a MongoDB operation: the configured query time limit,
|
|
7
|
+
* narrowed by any limit the request itself carries.
|
|
8
|
+
*
|
|
9
|
+
* Per-request limits arrive as params, which trusted middleware sets from the
|
|
10
|
+
* caller's authorization: an MCP OAuth grant, for example, caps every operation
|
|
11
|
+
* the AI client runs. They are intersected with the host application's
|
|
12
|
+
* configuration rather than replacing it, so a request can only ever ask for
|
|
13
|
+
* less than the host allows, never more.
|
|
14
|
+
*/
|
|
15
|
+
module.exports = function operationOptions(options, params, extra) {
|
|
16
|
+
return omitNullish({ ...extra, maxTimeMS: resolveMaxTimeMS(options, params) });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
module.exports.resolveMaxTimeMS = resolveMaxTimeMS;
|
|
20
|
+
|
|
21
|
+
function resolveMaxTimeMS(options, params) {
|
|
22
|
+
const requested = Number(params?.maxTimeMS);
|
|
23
|
+
const limits = [options?.maxTimeMS, Number.isFinite(requested) && requested > 0 ? requested : null].
|
|
24
|
+
filter(limit => limit != null);
|
|
25
|
+
return limits.length === 0 ? null : Math.min(...limits);
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const operationOptions = require('./operationOptions');
|
|
4
|
+
|
|
5
|
+
const readPreferenceRank = {
|
|
6
|
+
secondary: 0,
|
|
7
|
+
secondaryPreferred: 1,
|
|
8
|
+
primary: 2
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options to apply to a read against the application database: everything
|
|
13
|
+
* `operationOptions()` resolves, plus the read preference. MCP OAuth grants use
|
|
14
|
+
* the read preference to keep AI clients off the primary, so like the query time
|
|
15
|
+
* limit it is resolved to whichever of the configured and requested values is
|
|
16
|
+
* more restrictive.
|
|
17
|
+
*/
|
|
18
|
+
module.exports = function readOperationOptions(options, params, extra) {
|
|
19
|
+
return operationOptions(options, params, {
|
|
20
|
+
...extra,
|
|
21
|
+
readPreference: resolveReadPreference(options, params)
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resolveReadPreference(options, params) {
|
|
26
|
+
const preferences = [options?.readPreference, params?.readPreference].
|
|
27
|
+
filter(preference => readPreferenceRank[preference] != null);
|
|
28
|
+
return preferences.length === 0 ?
|
|
29
|
+
null :
|
|
30
|
+
preferences.reduce((result, preference) => readPreferenceRank[preference] < readPreferenceRank[result] ? preference : result);
|
|
31
|
+
}
|
package/backend/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const chatMessageSchema = require('./db/chatMessageSchema');
|
|
|
8
8
|
const chatThreadSchema = require('./db/chatThreadSchema');
|
|
9
9
|
const dashboardSchema = require('./db/dashboardSchema');
|
|
10
10
|
const dashboardResultSchema = require('./db/dashboardResultSchema');
|
|
11
|
+
const scriptSchema = require('./db/scriptSchema');
|
|
11
12
|
|
|
12
13
|
module.exports = function backend(db, studioConnection, options) {
|
|
13
14
|
db = db || mongoose.connection;
|
|
@@ -20,6 +21,7 @@ module.exports = function backend(db, studioConnection, options) {
|
|
|
20
21
|
studioConnection.model('__Studio_DashboardResult', dashboardResultSchema, 'studio__dashboardResults');
|
|
21
22
|
studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages');
|
|
22
23
|
studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads');
|
|
24
|
+
studioConnection.model('__Studio_Script', scriptSchema, 'studio__scripts');
|
|
23
25
|
|
|
24
26
|
let changeStream = null;
|
|
25
27
|
if (options?.changeStream) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { defaultMothershipURL } = require('../../constants');
|
|
4
|
+
|
|
5
|
+
async function post(path, params, options) {
|
|
6
|
+
const mothershipUrl = options?._mothershipUrl || defaultMothershipURL;
|
|
7
|
+
const mcpOAuthUrl = `${new URL(mothershipUrl).origin}/mcp-oauth`;
|
|
8
|
+
const response = await fetch(`${mcpOAuthUrl}/${path}`, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11
|
+
body: JSON.stringify(params)
|
|
12
|
+
});
|
|
13
|
+
const data = await response.json().catch(() => ({}));
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
const error = new Error(data?.message || `Mothership request failed with status ${response.status}`);
|
|
16
|
+
error.status = response.status;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
exports.MCPOAuthToken = {
|
|
23
|
+
introspectMCPAccessToken: (params, options) => post('MCPOAuthToken/introspectMCPAccessToken', params, options)
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
exports.Workspace = {
|
|
27
|
+
registerMCPResource: (params, options) => post('Workspace/registerMCPResource', params, options)
|
|
28
|
+
};
|
package/backend/mcp.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
4
|
+
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
5
|
+
|
|
6
|
+
const archetypeToZodSchema = require('./util/archetypeToZodSchema');
|
|
7
|
+
const authorize = require('./authorize');
|
|
8
|
+
const packageJson = require('../package.json');
|
|
9
|
+
|
|
10
|
+
// Params that only trusted middleware may set. `maxTimeMS` and `readPreference`
|
|
11
|
+
// are the limits from the caller's authorization, so an MCP client must not be
|
|
12
|
+
// able to name them.
|
|
13
|
+
const reservedParams = new Set([
|
|
14
|
+
'$workspaceId',
|
|
15
|
+
'authorization',
|
|
16
|
+
'initiatedBy',
|
|
17
|
+
'initiatedById',
|
|
18
|
+
'maxTimeMS',
|
|
19
|
+
'readPreference',
|
|
20
|
+
'roles',
|
|
21
|
+
'userId'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
25
|
+
|
|
26
|
+
module.exports = function mcp(backend) {
|
|
27
|
+
return async function mongooseStudioMCP(req, res) {
|
|
28
|
+
if (req.method !== 'POST') {
|
|
29
|
+
return res.status(405).json({
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
error: { code: -32000, message: 'Method not allowed' },
|
|
32
|
+
id: null
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const server = createServer(backend, req._internals || {}, req);
|
|
37
|
+
const transport = new StreamableHTTPServerTransport({
|
|
38
|
+
sessionIdGenerator: undefined,
|
|
39
|
+
enableJsonResponse: true
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await server.connect(transport);
|
|
44
|
+
await transport.handleRequest(req, res, req.body);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (!res.headersSent) {
|
|
47
|
+
res.status(500).json({
|
|
48
|
+
jsonrpc: '2.0',
|
|
49
|
+
error: { code: -32603, message: err.message },
|
|
50
|
+
id: null
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await transport.close().catch(() => {});
|
|
55
|
+
await server.close().catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function createServer(backend, requestContext, req) {
|
|
61
|
+
const server = new McpServer({
|
|
62
|
+
name: 'mongoose-studio' + (nodeEnv ? ` (-${nodeEnv})` : ''),
|
|
63
|
+
version: packageJson.version
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const namespace of ['Dashboard', 'Model', 'Script']) {
|
|
67
|
+
for (const [action, actionFn] of Object.entries(backend[namespace])) {
|
|
68
|
+
const actionName = `${namespace}.${action}`;
|
|
69
|
+
if (actionFn.mcp === false || !isAuthorized(actionName, requestContext.roles)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const tags = actionFn.tags || [];
|
|
73
|
+
const inputSchema = archetypeToZodSchema(actionFn.paramsType);
|
|
74
|
+
const internalParams = Object.fromEntries(
|
|
75
|
+
Object.keys(inputSchema.shape).filter(key => reservedParams.has(key)).map(key => [key, true])
|
|
76
|
+
);
|
|
77
|
+
server.registerTool(actionName, {
|
|
78
|
+
description: actionFn.description || `Run the Mongoose Studio ${actionName} action`,
|
|
79
|
+
inputSchema: inputSchema.omit(internalParams),
|
|
80
|
+
annotations: {
|
|
81
|
+
readOnlyHint: tags.includes('readOnly'),
|
|
82
|
+
destructiveHint: tags.includes('destructive'),
|
|
83
|
+
idempotentHint: tags.includes('readOnly')
|
|
84
|
+
}
|
|
85
|
+
}, async(args, extra) => {
|
|
86
|
+
const params = Object.fromEntries(
|
|
87
|
+
Object.entries(args || {}).filter(([key]) => !reservedParams.has(key))
|
|
88
|
+
);
|
|
89
|
+
Object.assign(params, requestContext);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
let result = actionFn(params, req, { setHeader: () => {} });
|
|
93
|
+
if (result != null && typeof result[Symbol.asyncIterator] === 'function') {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
const iterator = result[Symbol.asyncIterator]();
|
|
96
|
+
const abort = () => iterator.return?.();
|
|
97
|
+
extra.signal.addEventListener('abort', abort);
|
|
98
|
+
try {
|
|
99
|
+
for await (const chunk of { [Symbol.asyncIterator]: () => iterator }) {
|
|
100
|
+
chunks.push(chunk);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
extra.signal.removeEventListener('abort', abort);
|
|
104
|
+
}
|
|
105
|
+
result = chunks;
|
|
106
|
+
} else {
|
|
107
|
+
result = await result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { content: [{ type: 'text', text: stringifyResult(result) }] };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
isError: true,
|
|
114
|
+
content: [{ type: 'text', text: err?.message || 'Mongoose Studio action failed' }]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return server;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Only advertise the tools this request is actually allowed to call, so a
|
|
125
|
+
// read-only user or a read-only OAuth grant does not see write tools at all.
|
|
126
|
+
function isAuthorized(actionName, roles) {
|
|
127
|
+
try {
|
|
128
|
+
authorize(actionName, roles);
|
|
129
|
+
return true;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stringifyResult(result) {
|
|
136
|
+
if (typeof result === 'string') {
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
return JSON.stringify(result ?? null, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2);
|
|
140
|
+
}
|
package/backend/netlify.js
CHANGED
|
@@ -5,15 +5,16 @@ const { toNetlifyFunction } = require('extrovert');
|
|
|
5
5
|
const { defaultMothershipURL } = require('../constants');
|
|
6
6
|
|
|
7
7
|
module.exports = function netlify(conn, options) {
|
|
8
|
-
|
|
9
|
-
const
|
|
8
|
+
options = { ...options };
|
|
9
|
+
const backend = Backend(conn, options.studioConnection, options);
|
|
10
|
+
const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
|
|
10
11
|
|
|
11
12
|
let workspace = null;
|
|
12
13
|
|
|
13
14
|
return toNetlifyFunction(async function wrappedNetlifyFunction(params) {
|
|
14
15
|
const actionName = params?.action;
|
|
15
16
|
const authorization = params?.authorization;
|
|
16
|
-
if (options
|
|
17
|
+
if (options.apiKey) {
|
|
17
18
|
if (!authorization) {
|
|
18
19
|
throw new Error('Not authorized');
|
|
19
20
|
}
|
|
@@ -36,6 +37,7 @@ module.exports = function netlify(conn, options) {
|
|
|
36
37
|
return response;
|
|
37
38
|
})
|
|
38
39
|
.then(res => res.json()));
|
|
40
|
+
options.workspace = workspace;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const { user, roles } = await fetch(`${mothershipUrl}/me?`, {
|
package/backend/next.js
CHANGED
|
@@ -4,9 +4,10 @@ const Backend = require('./');
|
|
|
4
4
|
const { defaultMothershipURL } = require('../constants');
|
|
5
5
|
|
|
6
6
|
module.exports = function next(conn, options) {
|
|
7
|
-
|
|
7
|
+
options = { ...options };
|
|
8
|
+
const backend = Backend(conn, options.studioConnection, options);
|
|
8
9
|
|
|
9
|
-
const mothershipUrl = options
|
|
10
|
+
const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
|
|
10
11
|
let workspace = null;
|
|
11
12
|
|
|
12
13
|
return async function wrappedNextJSFunction(req, res) {
|
|
@@ -14,7 +15,7 @@ module.exports = function next(conn, options) {
|
|
|
14
15
|
const actionName = params?.action;
|
|
15
16
|
|
|
16
17
|
const authorization = params?.authorization;
|
|
17
|
-
if (options
|
|
18
|
+
if (options.apiKey) {
|
|
18
19
|
if (!authorization) {
|
|
19
20
|
throw new Error('Not authorized');
|
|
20
21
|
}
|
|
@@ -37,6 +38,7 @@ module.exports = function next(conn, options) {
|
|
|
37
38
|
return response;
|
|
38
39
|
})
|
|
39
40
|
.then(res => res.json()));
|
|
41
|
+
options.workspace = workspace;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const { user, roles } = await fetch(`${mothershipUrl}/me?`, {
|
|
@@ -5,7 +5,7 @@ const mongoose = require('mongoose');
|
|
|
5
5
|
const vm = require('vm');
|
|
6
6
|
const { createScriptDb } = require('./createScriptDb');
|
|
7
7
|
|
|
8
|
-
module.exports = function createSandbox(
|
|
8
|
+
module.exports = function createSandbox(db, { maxTimeMS, readPreference }) {
|
|
9
9
|
const logs = [];
|
|
10
10
|
|
|
11
11
|
// db must be a connection, not a Mongoose instance
|
|
@@ -13,7 +13,7 @@ module.exports = function createSandbox({ db, maxTimeMS }) {
|
|
|
13
13
|
db = db.connection;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const scriptDb = createScriptDb(db, { maxTimeMS });
|
|
16
|
+
const scriptDb = createScriptDb(db, { maxTimeMS, readPreference });
|
|
17
17
|
if (!scriptDb.db.Types) {
|
|
18
18
|
scriptDb.db.Types = mongoose.Types;
|
|
19
19
|
}
|