@mongoosejs/studio 0.5.13 → 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.
Files changed (86) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +27 -0
  3. package/backend/actions/ChatMessage/executeScript.js +15 -5
  4. package/backend/actions/ChatThread/createChatMessage.js +7 -2
  5. package/backend/actions/ChatThread/getChatThread.js +8 -2
  6. package/backend/actions/ChatThread/listChatThreads.js +6 -1
  7. package/backend/actions/ChatThread/shareChatThread.js +8 -2
  8. package/backend/actions/ChatThread/streamChatMessage.js +7 -2
  9. package/backend/actions/ChatThread/toggleAgentMode.js +8 -2
  10. package/backend/actions/Dashboard/createDashboard.js +3 -0
  11. package/backend/actions/Dashboard/deleteDashboard.js +13 -3
  12. package/backend/actions/Dashboard/getDashboard.js +33 -10
  13. package/backend/actions/Dashboard/getDashboards.js +9 -1
  14. package/backend/actions/Dashboard/updateDashboard.js +9 -2
  15. package/backend/actions/Model/addField.js +9 -2
  16. package/backend/actions/Model/analyzeSchema.js +16 -4
  17. package/backend/actions/Model/createChatMessage.js +3 -0
  18. package/backend/actions/Model/createDocument.js +3 -0
  19. package/backend/actions/Model/deleteDocument.js +9 -3
  20. package/backend/actions/Model/deleteDocuments.js +9 -1
  21. package/backend/actions/Model/dropCollection.js +3 -0
  22. package/backend/actions/Model/dropIndex.js +3 -0
  23. package/backend/actions/Model/executeDocumentScript.js +33 -26
  24. package/backend/actions/Model/exportQueryResults.js +13 -1
  25. package/backend/actions/Model/getCollectionInfo.js +15 -3
  26. package/backend/actions/Model/getDocument.js +13 -2
  27. package/backend/actions/Model/getDocuments.js +25 -5
  28. package/backend/actions/Model/getDocumentsStream.js +38 -18
  29. package/backend/actions/Model/getEstimatedDocumentCounts.js +15 -2
  30. package/backend/actions/Model/getIndexes.js +3 -0
  31. package/backend/actions/Model/getSuggestedProjection.js +3 -0
  32. package/backend/actions/Model/listModels.js +3 -0
  33. package/backend/actions/Model/streamChatMessage.js +3 -0
  34. package/backend/actions/Model/streamDocumentChanges.js +3 -0
  35. package/backend/actions/Model/updateDocument.js +10 -2
  36. package/backend/actions/Model/updateDocuments.js +10 -2
  37. package/backend/actions/Model/validateDocument.js +15 -2
  38. package/backend/actions/Script/createScript.js +36 -0
  39. package/backend/actions/Script/executeScript.js +89 -0
  40. package/backend/actions/Script/getScript.js +43 -0
  41. package/backend/actions/Script/index.js +5 -0
  42. package/backend/actions/Task/cancelTask.js +9 -3
  43. package/backend/actions/Task/getTaskOverview.js +7 -3
  44. package/backend/actions/Task/getTasks.js +7 -3
  45. package/backend/actions/Task/getTasksOverTime.js +7 -3
  46. package/backend/actions/Task/rescheduleTask.js +9 -3
  47. package/backend/actions/Task/runTask.js +9 -3
  48. package/backend/actions/index.js +1 -0
  49. package/backend/authorize.js +6 -1
  50. package/backend/db/scriptSchema.js +29 -0
  51. package/backend/helpers/omitNullish.js +5 -0
  52. package/backend/helpers/operationOptions.js +26 -0
  53. package/backend/helpers/readOperationOptions.js +31 -0
  54. package/backend/index.js +6 -4
  55. package/backend/integrations/mothership.js +28 -0
  56. package/backend/mcp.js +140 -0
  57. package/backend/netlify.js +5 -3
  58. package/backend/next.js +5 -3
  59. package/backend/sandbox/createSandbox.js +2 -2
  60. package/backend/sandbox/createScriptDb.js +60 -20
  61. package/backend/util/archetypeToZodSchema.js +74 -0
  62. package/docs/mcp-oauth.md +106 -0
  63. package/eslint.config.js +1 -0
  64. package/eval/agent/run.js +1 -1
  65. package/express.js +183 -41
  66. package/frontend/index.js +3 -0
  67. package/frontend/public/app.js +740 -124
  68. package/frontend/public/tw.css +76 -0
  69. package/frontend/src/api.js +27 -1
  70. package/frontend/src/index.js +9 -1
  71. package/frontend/src/mcp/mcp.html +55 -0
  72. package/frontend/src/mcp/mcp.js +27 -0
  73. package/frontend/src/models/models.html +29 -8
  74. package/frontend/src/models/models.js +30 -2
  75. package/frontend/src/navbar/navbar.html +8 -0
  76. package/frontend/src/navbar/navbar.js +3 -0
  77. package/frontend/src/routes.js +16 -0
  78. package/frontend/src/script/script.html +105 -0
  79. package/frontend/src/script/script.js +98 -0
  80. package/frontend/src/tooltip/tooltip.html +28 -0
  81. package/frontend/src/tooltip/tooltip.js +94 -0
  82. package/index.d.ts +10 -1
  83. package/local.js +2 -1
  84. package/nest.d.ts +4 -0
  85. package/package.json +5 -3
  86. package/vercel.d.ts +4 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const authorize = require('../../authorize');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const UpdateDocumentsParams = new Archetype({
7
8
  model: {
@@ -18,10 +19,13 @@ const UpdateDocumentsParams = new Archetype({
18
19
  },
19
20
  roles: {
20
21
  $type: ['string']
22
+ },
23
+ maxTimeMS: {
24
+ $type: 'number'
21
25
  }
22
26
  }).compile('UpdateDocumentsParams');
23
27
 
24
- module.exports = ({ db }) => async function updateDocuments(params) {
28
+ module.exports = ({ db, options }) => async function updateDocuments(params) {
25
29
  const { model, _id, update, roles } = new UpdateDocumentsParams(params);
26
30
 
27
31
  await authorize('Model.updateDocuments', roles);
@@ -60,7 +64,11 @@ module.exports = ({ db }) => async function updateDocuments(params) {
60
64
  }
61
65
 
62
66
  const result = await Model.
63
- updateMany({ _id: { $in: _id } }, updateOperation, { overwriteImmutable: true, runValidators: false });
67
+ updateMany({ _id: { $in: _id } }, updateOperation, { overwriteImmutable: true, runValidators: false }).
68
+ setOptions(operationOptions(options, params));
64
69
 
65
70
  return { result };
66
71
  };
72
+
73
+ module.exports.paramsType = UpdateDocumentsParams;
74
+ module.exports.tags = [];
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const authorize = require('../../authorize');
5
+ const readOperationOptions = require('../../helpers/readOperationOptions');
5
6
  const validateDocumentWithTimeout = require('../../helpers/validateDocumentWithTimeout');
6
7
 
7
8
  const ValidateDocumentParams = new Archetype({
@@ -15,10 +16,17 @@ const ValidateDocumentParams = new Archetype({
15
16
  },
16
17
  roles: {
17
18
  $type: ['string']
19
+ },
20
+ maxTimeMS: {
21
+ $type: 'number'
22
+ },
23
+ readPreference: {
24
+ $type: 'string',
25
+ $enum: ['secondary', 'secondaryPreferred', 'primary']
18
26
  }
19
27
  }).compile('ValidateDocumentParams');
20
28
 
21
- module.exports = ({ db }) => async function validateDocument(params) {
29
+ module.exports = ({ db, options }) => async function validateDocument(params) {
22
30
  const { model, documentId, roles } = new ValidateDocumentParams(params);
23
31
 
24
32
  await authorize('Model.validateDocument', roles);
@@ -28,8 +36,13 @@ module.exports = ({ db }) => async function validateDocument(params) {
28
36
  throw new Error(`Model ${model} not found`);
29
37
  }
30
38
 
31
- const doc = await Model.findById(documentId).setOptions({ sanitizeFilter: true }).orFail();
39
+ const doc = await Model.findById(documentId).
40
+ setOptions(readOperationOptions(options, { sanitizeFilter: true })).
41
+ orFail();
32
42
  return {
33
43
  result: await validateDocumentWithTimeout(doc)
34
44
  };
35
45
  };
46
+
47
+ module.exports.paramsType = ValidateDocumentParams;
48
+ module.exports.tags = ['readOnly'];
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const Archetype = require('archetype');
4
+ const mongoose = require('mongoose');
5
+
6
+ const authorize = require('../../authorize');
7
+
8
+ const CreateScriptParams = new Archetype({
9
+ initiatedById: {
10
+ $type: mongoose.Types.ObjectId
11
+ },
12
+ script: {
13
+ $type: 'string',
14
+ $required: true
15
+ },
16
+ roles: {
17
+ $type: ['string']
18
+ }
19
+ }).compile('CreateScriptParams');
20
+
21
+ module.exports = ({ studioConnection, options }) => async function createScript(params) {
22
+ const { initiatedById, script, roles } = new CreateScriptParams(params);
23
+
24
+ await authorize('Script.createScript', roles);
25
+
26
+ const Script = studioConnection.model('__Studio_Script');
27
+ const createdScript = await Script.create({ script, userId: initiatedById });
28
+ const baseUrl = options?.workspace?.baseUrl?.replace(/\/+$/, '');
29
+ const url = `${baseUrl ? `${baseUrl}/` : ''}#/script/${createdScript._id}`;
30
+
31
+ return { script: createdScript, url };
32
+ };
33
+
34
+ module.exports.paramsType = CreateScriptParams;
35
+ module.exports.tags = [];
36
+ module.exports.description = 'Create a script for the user to review, edit, dry run, or run in Mongoose Studio. This tool does not execute the script and returns a review URL.';
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const Archetype = require('archetype');
4
+ const mongoose = require('mongoose');
5
+
6
+ const authorize = require('../../authorize');
7
+ const readOperationOptions = require('../../helpers/readOperationOptions');
8
+ const createSandbox = require('../../sandbox/createSandbox');
9
+
10
+ const ExecuteScriptParams = new Archetype({
11
+ initiatedById: {
12
+ $type: mongoose.Types.ObjectId
13
+ },
14
+ scriptId: {
15
+ $type: mongoose.Types.ObjectId,
16
+ $required: true
17
+ },
18
+ script: {
19
+ $type: 'string'
20
+ },
21
+ dryRun: {
22
+ $type: 'boolean',
23
+ $default: false
24
+ },
25
+ roles: {
26
+ $type: ['string']
27
+ },
28
+ maxTimeMS: {
29
+ $type: 'number'
30
+ },
31
+ readPreference: {
32
+ $type: 'string',
33
+ $enum: ['secondary', 'secondaryPreferred', 'primary']
34
+ }
35
+ }).compile('ExecuteScriptParams');
36
+
37
+ module.exports = ({ db, studioConnection, options }) => async function executeScript(params) {
38
+ const { initiatedById, scriptId, script, dryRun, roles } = new ExecuteScriptParams(params);
39
+
40
+ await authorize('Script.executeScript', roles);
41
+
42
+ const Script = studioConnection.model('__Studio_Script');
43
+ const scriptDoc = await Script.findById(scriptId);
44
+ if (scriptDoc == null) {
45
+ throw new Error('Script not found');
46
+ }
47
+ if (initiatedById && scriptDoc.userId?.toString() !== initiatedById.toString()) {
48
+ throw new Error('Unauthorized');
49
+ }
50
+
51
+ let output = null;
52
+ let error = null;
53
+
54
+ if (script != null) {
55
+ scriptDoc.script = script;
56
+ }
57
+ scriptDoc.dryRun = dryRun;
58
+ scriptDoc.executionStartedAt = new Date();
59
+ scriptDoc.executionFinishedAt = null;
60
+ scriptDoc.executionResult = null;
61
+ await scriptDoc.save();
62
+
63
+ const sandbox = createSandbox(db, { ...readOperationOptions(options, params) });
64
+ try {
65
+ output = await sandbox.runScript({ script: scriptDoc.script, dryRun });
66
+ } catch (err) {
67
+ error = err;
68
+ } finally {
69
+ try {
70
+ await sandbox.close();
71
+ } catch (_) {
72
+ // Ignore sandbox cleanup errors so they do not mask the primary result.
73
+ }
74
+ }
75
+
76
+ scriptDoc.executionFinishedAt = new Date();
77
+ scriptDoc.executionResult = { output, logs: sandbox.getLogs(), error: error?.message ?? null };
78
+ await scriptDoc.save();
79
+
80
+ if (error != null) {
81
+ throw new Error(`Script execution failed: ${error.message}`);
82
+ }
83
+
84
+ return { script: scriptDoc };
85
+ };
86
+
87
+ module.exports.paramsType = ExecuteScriptParams;
88
+ module.exports.tags = [];
89
+ module.exports.mcp = false;
@@ -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'];
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ exports.createScript = require('./createScript');
4
+ exports.executeScript = require('./executeScript');
5
+ exports.getScript = require('./getScript');
@@ -2,23 +2,29 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const mongoose = require('mongoose');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const CancelTaskParams = new Archetype({
7
8
  taskId: {
8
9
  $type: mongoose.Types.ObjectId,
9
10
  $required: true
11
+ },
12
+ maxTimeMS: {
13
+ $type: 'number'
10
14
  }
11
15
  }).compile('CancelTaskParams');
12
16
 
13
- module.exports = ({ db }) => async function cancelTask(params) {
17
+ module.exports = ({ db, options }) => async function cancelTask(params) {
14
18
  params = new CancelTaskParams(params);
15
19
  const { taskId } = params;
16
20
  const { Task } = db.models;
17
21
 
18
- const task = await Task.findOne({ _id: taskId }).orFail();
22
+ const task = await Task.findOne({ _id: taskId }).
23
+ setOptions(operationOptions(options, params)).
24
+ orFail();
19
25
 
20
26
  const cancelledTask = await Task.cancelTask({ _id: taskId });
21
27
  return {
22
28
  task: cancelledTask
23
29
  };
24
- };
30
+ };
@@ -2,12 +2,16 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const escape = require('regexp.escape');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const GetTaskOverviewParams = new Archetype({
7
8
  start: { $type: Date },
8
9
  end: { $type: Date },
9
10
  status: { $type: 'string' },
10
- name: { $type: 'string' }
11
+ name: { $type: 'string' },
12
+ maxTimeMS: {
13
+ $type: 'number'
14
+ }
11
15
  }).compile('GetTaskOverviewParams');
12
16
 
13
17
  /** Statuses shown on the Task overview page. */
@@ -34,7 +38,7 @@ function buildMatch(params) {
34
38
  return match;
35
39
  }
36
40
 
37
- module.exports = ({ db }) => async function getTaskOverview(params) {
41
+ module.exports = ({ db, options }) => async function getTaskOverview(params) {
38
42
  params = new GetTaskOverviewParams(params);
39
43
  if (typeof params.status === 'string') params.status = params.status.trim();
40
44
  if (typeof params.name === 'string') params.name = params.name.trim();
@@ -93,7 +97,7 @@ module.exports = ({ db }) => async function getTaskOverview(params) {
93
97
  }
94
98
  ];
95
99
 
96
- const [result] = await Task.aggregate(pipeline);
100
+ const [result] = await Task.aggregate(pipeline).option(operationOptions(options, params));
97
101
 
98
102
  return {
99
103
  statusCounts: result.statusCounts?.[0] ?? {},
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const escape = require('regexp.escape');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const GetTasksParams = new Archetype({
7
8
  start: { $type: Date },
@@ -9,7 +10,10 @@ const GetTasksParams = new Archetype({
9
10
  status: { $type: 'string' },
10
11
  name: { $type: 'string' },
11
12
  skip: { $type: 'number', $default: 0 },
12
- limit: { $type: 'number', $default: 100 }
13
+ limit: { $type: 'number', $default: 100 },
14
+ maxTimeMS: {
15
+ $type: 'number'
16
+ }
13
17
  }).compile('GetTasksParams');
14
18
 
15
19
  const ALL_STATUSES = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
@@ -55,7 +59,7 @@ const TASK_PROJECT_STAGE = {
55
59
  parameters: '$payload'
56
60
  };
57
61
 
58
- module.exports = ({ db }) => async function getTasks(params) {
62
+ module.exports = ({ db, options }) => async function getTasks(params) {
59
63
  params = new GetTasksParams(params);
60
64
  if (typeof params.status === 'string') params.status = params.status.trim();
61
65
  if (typeof params.name === 'string') params.name = params.name.trim();
@@ -96,7 +100,7 @@ module.exports = ({ db }) => async function getTasks(params) {
96
100
  }
97
101
  ];
98
102
 
99
- const [result] = await Task.aggregate(pipeline);
103
+ const [result] = await Task.aggregate(pipeline).option(operationOptions(options, params));
100
104
  const tasks = result.tasks || [];
101
105
  const numDocs = (result.count && result.count[0] && result.count[0].total) || 0;
102
106
  const statusCounts = result.statusCounts?.[0] ?? {};
@@ -1,16 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  const Archetype = require('archetype');
4
+ const operationOptions = require('../../helpers/operationOptions');
4
5
 
5
6
  const GetTasksOverTimeParams = new Archetype({
6
7
  start: { $type: Date },
7
8
  end: { $type: Date },
8
- bucketSizeMs: { $type: 'number' }
9
+ bucketSizeMs: { $type: 'number' },
10
+ maxTimeMS: {
11
+ $type: 'number'
12
+ }
9
13
  }).compile('GetTasksOverTimeParams');
10
14
 
11
15
  const TRACKED_STATUSES = ['succeeded', 'failed', 'cancelled'];
12
16
 
13
- module.exports = ({ db }) => async function getTasksOverTime(params) {
17
+ module.exports = ({ db, options }) => async function getTasksOverTime(params) {
14
18
  params = new GetTasksOverTimeParams(params);
15
19
  const { Task } = db.models;
16
20
  const { start, end, bucketSizeMs } = params;
@@ -54,7 +58,7 @@ module.exports = ({ db }) => async function getTasksOverTime(params) {
54
58
  { $sort: { _id: 1 } }
55
59
  ];
56
60
 
57
- const results = await Task.aggregate(pipeline);
61
+ const results = await Task.aggregate(pipeline).option(operationOptions(options, params));
58
62
 
59
63
  return results.map(r => {
60
64
  const bucket = { timestamp: r._id, succeeded: 0, failed: 0, cancelled: 0 };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const mongoose = require('mongoose');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const RescheduleTaskParams = new Archetype({
7
8
  taskId: {
@@ -11,15 +12,20 @@ const RescheduleTaskParams = new Archetype({
11
12
  scheduledAt: {
12
13
  $type: Date,
13
14
  $required: true
15
+ },
16
+ maxTimeMS: {
17
+ $type: 'number'
14
18
  }
15
19
  }).compile('RescheduleTaskParams');
16
20
 
17
- module.exports = ({ db }) => async function rescheduleTask(params) {
21
+ module.exports = ({ db, options }) => async function rescheduleTask(params) {
18
22
  params = new RescheduleTaskParams(params);
19
23
  const { taskId, scheduledAt } = params;
20
24
  const { Task } = db.models;
21
25
 
22
- const task = await Task.findOne({ _id: taskId }).orFail();
26
+ const task = await Task.findOne({ _id: taskId }).
27
+ setOptions(operationOptions(options, params)).
28
+ orFail();
23
29
 
24
30
  if (scheduledAt < Date.now()) {
25
31
  throw new Error('Cannot reschedule a task for the past');
@@ -36,4 +42,4 @@ module.exports = ({ db }) => async function rescheduleTask(params) {
36
42
  return {
37
43
  task
38
44
  };
39
- };
45
+ };
@@ -2,24 +2,30 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const mongoose = require('mongoose');
5
+ const operationOptions = require('../../helpers/operationOptions');
5
6
 
6
7
  const RunTaskParams = new Archetype({
7
8
  taskId: {
8
9
  $type: mongoose.Types.ObjectId,
9
10
  $required: true
11
+ },
12
+ maxTimeMS: {
13
+ $type: 'number'
10
14
  }
11
15
  }).compile('RunTaskParams');
12
16
 
13
- module.exports = ({ db }) => async function runTask(params) {
17
+ module.exports = ({ db, options }) => async function runTask(params) {
14
18
  params = new RunTaskParams(params);
15
19
  const { taskId } = params;
16
20
  const { Task } = db.models;
17
21
 
18
- const task = await Task.findOne({ _id: taskId }).orFail();
22
+ const task = await Task.findOne({ _id: taskId }).
23
+ setOptions(operationOptions(options, params)).
24
+ orFail();
19
25
 
20
26
  const executedTask = await Task.execute(task);
21
27
 
22
28
  return {
23
29
  task: executedTask
24
30
  };
25
- };
31
+ };
@@ -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');
@@ -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,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = function omitNullish(obj) {
4
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value != null));
5
+ };
@@ -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;
@@ -16,10 +17,11 @@ module.exports = function backend(db, studioConnection, options) {
16
17
  }
17
18
 
18
19
  studioConnection = studioConnection ?? db;
19
- const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards');
20
- const DashboardResult = studioConnection.model('__Studio_DashboardResult', dashboardResultSchema, 'studio__dashboardResults');
21
- const ChatMessage = studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages');
22
- const ChatThread = studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads');
20
+ studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards');
21
+ studioConnection.model('__Studio_DashboardResult', dashboardResultSchema, 'studio__dashboardResults');
22
+ studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages');
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
+ };