@mongoosejs/studio 0.5.14 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +69 -2
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +25 -0
  4. package/backend/actions/ChatMessage/executeScript.js +14 -6
  5. package/backend/actions/ChatThread/createChatMessage.js +7 -4
  6. package/backend/actions/ChatThread/getChatThread.js +7 -4
  7. package/backend/actions/ChatThread/listChatThreads.js +5 -2
  8. package/backend/actions/ChatThread/shareChatThread.js +5 -2
  9. package/backend/actions/ChatThread/streamChatMessage.js +7 -4
  10. package/backend/actions/ChatThread/toggleAgentMode.js +5 -2
  11. package/backend/actions/Dashboard/createDashboard.js +3 -0
  12. package/backend/actions/Dashboard/deleteDashboard.js +9 -3
  13. package/backend/actions/Dashboard/getDashboard.js +23 -11
  14. package/backend/actions/Dashboard/getDashboards.js +8 -2
  15. package/backend/actions/Dashboard/updateDashboard.js +8 -2
  16. package/backend/actions/Model/addField.js +8 -2
  17. package/backend/actions/Model/analyzeSchema.js +15 -5
  18. package/backend/actions/Model/createChatMessage.js +3 -0
  19. package/backend/actions/Model/createDocument.js +3 -0
  20. package/backend/actions/Model/deleteDocument.js +8 -2
  21. package/backend/actions/Model/deleteDocuments.js +8 -2
  22. package/backend/actions/Model/dropCollection.js +3 -0
  23. package/backend/actions/Model/dropIndex.js +3 -0
  24. package/backend/actions/Model/executeDocumentScript.js +14 -3
  25. package/backend/actions/Model/exportQueryResults.js +12 -2
  26. package/backend/actions/Model/getCollectionInfo.js +14 -4
  27. package/backend/actions/Model/getDocument.js +12 -2
  28. package/backend/actions/Model/getDocuments.js +13 -3
  29. package/backend/actions/Model/getDocumentsStream.js +13 -3
  30. package/backend/actions/Model/getEstimatedDocumentCounts.js +12 -2
  31. package/backend/actions/Model/getIndexes.js +3 -0
  32. package/backend/actions/Model/getSuggestedProjection.js +3 -0
  33. package/backend/actions/Model/listModels.js +3 -0
  34. package/backend/actions/Model/streamChatMessage.js +3 -0
  35. package/backend/actions/Model/streamDocumentChanges.js +3 -0
  36. package/backend/actions/Model/updateDocument.js +8 -2
  37. package/backend/actions/Model/updateDocuments.js +8 -2
  38. package/backend/actions/Model/validateDocument.js +12 -2
  39. package/backend/actions/Script/createScript.js +36 -0
  40. package/backend/actions/Script/executeScript.js +89 -0
  41. package/backend/actions/Script/getScript.js +43 -0
  42. package/backend/actions/Script/index.js +5 -0
  43. package/backend/actions/Task/cancelTask.js +5 -2
  44. package/backend/actions/Task/getTaskOverview.js +6 -3
  45. package/backend/actions/Task/getTasks.js +6 -3
  46. package/backend/actions/Task/getTasksOverTime.js +6 -3
  47. package/backend/actions/Task/rescheduleTask.js +5 -2
  48. package/backend/actions/Task/runTask.js +5 -2
  49. package/backend/actions/index.js +1 -0
  50. package/backend/authorize.js +6 -1
  51. package/backend/db/scriptSchema.js +29 -0
  52. package/backend/helpers/operationOptions.js +26 -0
  53. package/backend/helpers/readOperationOptions.js +31 -0
  54. package/backend/index.js +2 -0
  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 +40 -23
  61. package/backend/util/archetypeToZodSchema.js +74 -0
  62. package/docs/mcp-oauth.md +107 -0
  63. package/eslint.config.js +1 -0
  64. package/eval/agent/run.js +1 -1
  65. package/express.js +184 -41
  66. package/frontend/index.js +3 -0
  67. package/frontend/public/app.js +441 -48
  68. package/frontend/public/tw.css +8 -0
  69. package/frontend/src/api.js +22 -0
  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/navbar/navbar.html +8 -0
  74. package/frontend/src/navbar/navbar.js +3 -0
  75. package/frontend/src/routes.js +16 -0
  76. package/frontend/src/script/script.html +105 -0
  77. package/frontend/src/script/script.js +98 -0
  78. package/index.d.ts +10 -1
  79. package/nest.d.ts +4 -0
  80. package/package.json +5 -3
  81. package/vercel.d.ts +2 -0
  82. package/CLAUDE.md +0 -7
@@ -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,12 +2,15 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const mongoose = require('mongoose');
5
- const omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS })).
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 omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS }));
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 omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS }));
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 omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS }));
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 omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS })).
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 omitNullish = require('../../helpers/omitNullish');
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(omitNullish({ maxTimeMS: options?.maxTimeMS })).
23
+ setOptions(operationOptions(options, params)).
21
24
  orFail();
22
25
 
23
26
  const executedTask = await Task.execute(task);
@@ -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,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
+ }
@@ -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
- const backend = Backend(conn, options?.studioConnection, options);
9
- const mothershipUrl = options?._mothershipUrl || defaultMothershipURL;
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?.apiKey) {
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
- const backend = Backend(conn, options?.studioConnection, options);
7
+ options = { ...options };
8
+ const backend = Backend(conn, options.studioConnection, options);
8
9
 
9
- const mothershipUrl = options?._mothershipUrl || defaultMothershipURL;
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?.apiKey) {
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?`, {