@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
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?`, {
@@ -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({ db }) {
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 }) {
13
13
  db = db.connection;
14
14
  }
15
15
 
16
- const scriptDb = createScriptDb(db);
16
+ const scriptDb = createScriptDb(db, { maxTimeMS, readPreference });
17
17
  if (!scriptDb.db.Types) {
18
18
  scriptDb.db.Types = mongoose.Types;
19
19
  }
@@ -1,8 +1,24 @@
1
1
  'use strict';
2
2
 
3
+ const omitNullish = require('../helpers/omitNullish');
4
+
3
5
  const wrappedCollections = new WeakSet();
4
6
 
7
+ // Read preference only applies to reads, so it is injected for these methods
8
+ // only. Applying it to a write would be meaningless at best and rejected by the
9
+ // driver at worst.
10
+ const readMethods = new Set([
11
+ 'aggregate',
12
+ 'countDocuments',
13
+ 'distinct',
14
+ 'estimatedDocumentCount',
15
+ 'find',
16
+ 'findOne',
17
+ 'listIndexes'
18
+ ]);
19
+
5
20
  const collectionMethodOptionsIndex = new Map([
21
+ ['aggregate', 1],
6
22
  ['bulkWrite', 1],
7
23
  ['countDocuments', 1],
8
24
  ['createIndex', 1],
@@ -21,26 +37,37 @@ const collectionMethodOptionsIndex = new Map([
21
37
  ['findOneAndUpdate', 2],
22
38
  ['insertMany', 1],
23
39
  ['insertOne', 1],
40
+ ['listIndexes', 0],
24
41
  ['rename', 1],
25
42
  ['replaceOne', 2],
26
43
  ['updateMany', 2],
27
44
  ['updateOne', 2]
28
45
  ]);
29
46
 
30
- function createScriptDb(db) {
47
+ function createScriptDb(db, options = {}) {
31
48
  const sourceConnection = db;
32
49
 
33
50
  const scriptConnection = sourceConnection.useDb(sourceConnection.name, { useCache: false });
51
+ // Mongoose useDb() shares the source connection's options object. Copy it
52
+ // before setting sandbox-only options so the application connection remains untouched.
53
+ scriptConnection.options = { ...scriptConnection.options };
34
54
  scriptConnection.config = {
35
55
  ...scriptConnection.config,
36
56
  autoCreate: false,
37
57
  autoIndex: false
38
58
  };
59
+ if (options.maxTimeMS != null) {
60
+ scriptConnection.set('maxTimeMS', options.maxTimeMS);
61
+ }
39
62
 
40
63
  let dryRunSession = null;
41
- const getSession = () => dryRunSession;
42
- cloneModels(sourceConnection, scriptConnection, getSession);
43
- wrapCollectionAccessors(scriptConnection, getSession);
64
+ const getOperationOptions = () => ({
65
+ session: dryRunSession,
66
+ maxTimeMS: options.maxTimeMS,
67
+ readPreference: options.readPreference
68
+ });
69
+ cloneModels(sourceConnection, scriptConnection, getOperationOptions);
70
+ wrapCollectionAccessors(scriptConnection, getOperationOptions);
44
71
 
45
72
  const originalDebug = sourceConnection.options?.debug;
46
73
  scriptConnection.set('debug', function() {
@@ -68,14 +95,14 @@ function createScriptDb(db) {
68
95
  };
69
96
  }
70
97
 
71
- function cloneModels(sourceConnection, scriptConnection, getSession) {
98
+ function cloneModels(sourceConnection, scriptConnection, getOperationOptions) {
72
99
  for (const Model of Object.values(sourceConnection.models ?? {})) {
73
100
  if (Model?.schema == null || typeof scriptConnection.model !== 'function') {
74
101
  continue;
75
102
  }
76
103
 
77
104
  const ClonedModel = scriptConnection.model(Model.modelName, Model.schema, getCollectionName(Model));
78
- wrapModelCollections(ClonedModel, getSession);
105
+ wrapModelCollections(ClonedModel, getOperationOptions);
79
106
  }
80
107
  }
81
108
 
@@ -83,13 +110,13 @@ function getCollectionName(Model) {
83
110
  return Model.collection?.collectionName ?? Model.collection?.name ?? Model.$__collection?.collectionName;
84
111
  }
85
112
 
86
- function wrapModelCollections(Model, getSession) {
113
+ function wrapModelCollections(Model, getOperationOptions) {
87
114
  const collection = Model?.collection ?? Model?.$__collection;
88
115
  if (collection == null) {
89
116
  return;
90
117
  }
91
118
 
92
- const wrappedCollection = wrapCollection(collection, getSession);
119
+ const wrappedCollection = wrapCollection(collection, getOperationOptions);
93
120
  Model.collection = wrappedCollection;
94
121
  Model.$__collection = wrappedCollection;
95
122
 
@@ -108,7 +135,7 @@ function setModelCollectionSymbols(target, collection) {
108
135
  }
109
136
  }
110
137
 
111
- function wrapCollectionAccessors(scriptConnection, getSession) {
138
+ function wrapCollectionAccessors(scriptConnection, getOperationOptions) {
112
139
  const wrappedCollectionCache = new WeakMap();
113
140
 
114
141
  const nativeDb = scriptConnection.db;
@@ -125,19 +152,19 @@ function wrapCollectionAccessors(scriptConnection, getSession) {
125
152
  collection.collection = originalDbCollection.call(nativeDb, collection.name);
126
153
  }
127
154
  }
128
- return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession);
155
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
129
156
  };
130
157
  }
131
158
 
132
159
  if (originalDbCollection != null) {
133
160
  nativeDb.collection = function() {
134
161
  const collection = originalDbCollection.apply(this, arguments);
135
- return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession);
162
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
136
163
  };
137
164
  }
138
165
  }
139
166
 
140
- function getOrCreateWrappedCollection(cache, collection, getSession) {
167
+ function getOrCreateWrappedCollection(cache, collection, getOperationOptions) {
141
168
  if (collection == null || (typeof collection !== 'object' && typeof collection !== 'function')) {
142
169
  return collection;
143
170
  }
@@ -147,12 +174,12 @@ function getOrCreateWrappedCollection(cache, collection, getSession) {
147
174
  if (cache.has(collection)) {
148
175
  return cache.get(collection);
149
176
  }
150
- const wrapped = wrapCollection(collection, getSession);
177
+ const wrapped = wrapCollection(collection, getOperationOptions);
151
178
  cache.set(collection, wrapped);
152
179
  return wrapped;
153
180
  }
154
181
 
155
- function wrapCollection(collection, getSession) {
182
+ function wrapCollection(collection, getOperationOptions) {
156
183
  if (wrappedCollections.has(collection)) {
157
184
  return collection;
158
185
  }
@@ -165,15 +192,20 @@ function wrapCollection(collection, getSession) {
165
192
  }
166
193
 
167
194
  wrapped[methodName] = function() {
168
- const args = addSessionOption(Array.from(arguments), optionsIndex, getSession());
195
+ const { session, maxTimeMS, readPreference } = getOperationOptions();
196
+ const args = addOperationOptions(Array.from(arguments), optionsIndex, {
197
+ session,
198
+ maxTimeMS,
199
+ readPreference: readMethods.has(methodName) ? readPreference : null
200
+ });
169
201
  return method.apply(collection, args);
170
202
  };
171
203
  }
172
204
  return wrapped;
173
205
  }
174
206
 
175
- function addSessionOption(args, optionsIndex, session) {
176
- if (session == null) {
207
+ function addOperationOptions(args, optionsIndex, { session, maxTimeMS, readPreference }) {
208
+ if (session == null && maxTimeMS == null && readPreference == null) {
177
209
  return args;
178
210
  }
179
211
 
@@ -184,14 +216,22 @@ function addSessionOption(args, optionsIndex, session) {
184
216
  const options = args[optionsIndex];
185
217
 
186
218
  if (options != null && typeof options !== 'object') {
187
- throw new Error('Cannot run dry run on script where options arg is a non-object');
219
+ if (session != null) {
220
+ throw new Error('Cannot run dry run on script where options arg is a non-object');
221
+ }
222
+ throw new Error('Cannot apply Studio operation options to script where options arg is a non-object');
188
223
  }
189
224
 
190
- if (options?.session != null) {
225
+ if (session != null && options?.session != null) {
191
226
  throw new Error('Cannot run dry run on script that uses sessions');
192
227
  }
193
228
 
194
- args[optionsIndex] = { ...(options ?? {}), session };
229
+ args[optionsIndex] = omitNullish({
230
+ ...(options ?? {}),
231
+ maxTimeMS: maxTimeMS ?? options?.maxTimeMS,
232
+ readPreference: readPreference ?? options?.readPreference,
233
+ session: session ?? options?.session
234
+ });
195
235
  return args;
196
236
  }
197
237
 
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const Archetype = require('archetype');
4
+ const mongoose = require('mongoose');
5
+ const z = require('zod/v4');
6
+
7
+ module.exports = function archetypeToZodSchema(paramsType) {
8
+ if (paramsType?.schema?.json == null) {
9
+ throw new Error('Expected a compiled Archetype params type');
10
+ }
11
+
12
+ return objectToZodSchema(paramsType.schema.json());
13
+ };
14
+
15
+ function objectToZodSchema(definition) {
16
+ return z.object(Object.fromEntries(
17
+ Object.entries(definition).map(([path, options]) => [path, pathToZodSchema(options)])
18
+ ));
19
+ }
20
+
21
+ function pathToZodSchema(options) {
22
+ if (options == null || typeof options !== 'object' || Array.isArray(options)) {
23
+ options = { $type: options };
24
+ } else if (!Object.prototype.hasOwnProperty.call(options, '$type')) {
25
+ options = { $type: Object, $schema: options };
26
+ }
27
+
28
+ let schema = typeToZodSchema(options.$type, options.$schema);
29
+ if (Array.isArray(options.$enum) && options.$enum.length > 0) {
30
+ schema = z.union(options.$enum.map(value => z.literal(value)));
31
+ }
32
+ if (typeof options.$validate === 'function') {
33
+ schema = schema.refine(value => {
34
+ try {
35
+ options.$validate(value, options);
36
+ return true;
37
+ } catch (_) {
38
+ return false;
39
+ }
40
+ });
41
+ }
42
+ if (!options.$required || options.$default !== undefined) {
43
+ schema = schema.optional();
44
+ }
45
+ if (options.$default !== undefined && typeof options.$default !== 'function') {
46
+ schema = schema.meta({ default: options.$default });
47
+ }
48
+ return schema;
49
+ }
50
+
51
+ function typeToZodSchema(type, nestedSchema) {
52
+ if (Array.isArray(type)) {
53
+ return z.array(typeToZodSchema(type[0]));
54
+ }
55
+ if (type === Archetype.Any) {
56
+ return z.unknown();
57
+ }
58
+ if (type === 'string' || type === String) {
59
+ return z.string();
60
+ }
61
+ if (type === 'number' || type === Number) {
62
+ return z.number();
63
+ }
64
+ if (type === 'boolean' || type === Boolean) {
65
+ return z.boolean();
66
+ }
67
+ if (type === Date || type === mongoose.Types.ObjectId) {
68
+ return z.string();
69
+ }
70
+ if (type === Object) {
71
+ return nestedSchema ? objectToZodSchema(nestedSchema) : z.record(z.string(), z.unknown());
72
+ }
73
+ return z.unknown();
74
+ }
@@ -0,0 +1,106 @@
1
+ # MCP OAuth
2
+
3
+ Mongoose Studio lets a user connect an MCP client, such as ChatGPT or Claude, to a Studio deployment using OAuth 2.1.
4
+ The client never receives MongoDB credentials, a connection string, or the workspace API key.
5
+ It receives a short-lived access token that only works against one Studio deployment and only does what the user approved.
6
+
7
+ ## Who does what
8
+
9
+ The Studio deployment in your application is the OAuth *protected resource*.
10
+ The Mongoose Studio mothership is the OAuth *authorization server*.
11
+
12
+ Studio therefore never issues, stores, or validates MCP tokens by itself.
13
+ It publishes metadata that tells clients where the authorization server is, and it asks the authorization server to resolve each incoming token.
14
+
15
+ ## The flow
16
+
17
+ 1. The user adds the Studio MCP URL, for example `https://app.example.com/studio/mcp`, to ChatGPT or Claude.
18
+ 2. The client calls the MCP endpoint with no token and gets a `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` header.
19
+ 3. The client fetches that protected resource metadata document from Studio, which names the authorization server and the resource identifier.
20
+ 4. The client fetches the authorization server metadata, registers itself dynamically, and opens the Studio authorization page in a browser.
21
+ 5. The user signs in to Mongoose Studio if necessary, reviews the access being delegated, adjusts it if they have a choice, and authorizes.
22
+ 6. The authorization server redirects back to the client with a single-use authorization code.
23
+ 7. The client exchanges the code plus its PKCE verifier for a short-lived access token and a refresh token.
24
+ 8. On every MCP request, Studio asks the authorization server to resolve the access token, and runs the request under the policy it returns.
25
+
26
+ Authorization Code with PKCE (`S256`) is required, and there are no client secrets: MCP clients are public clients.
27
+ Access tokens live 15 minutes.
28
+ Refresh tokens live 30 days and rotate on every use, so a user does not have to reconnect the client regularly.
29
+
30
+ ## Authorization grants
31
+
32
+ Approving the authorization page creates an `MCPOAuthGrant` on the mothership.
33
+ The grant records who delegated the access, the workspace and Studio deployment it applies to, which OAuth client it was issued to, and the capabilities that were approved:
34
+
35
+ ```javascript
36
+ {
37
+ userId,
38
+ workspaceId,
39
+ resource, // canonical URL of the Studio MCP endpoint, the environment being accessed
40
+ oauthClientId,
41
+ oauthClientName,
42
+ capabilities: {
43
+ read: true,
44
+ write: false,
45
+ readPreference: 'secondary',
46
+ maxTimeMS: 5000,
47
+ collections: '*'
48
+ },
49
+ lastUsedAt,
50
+ revokedAt
51
+ }
52
+ ```
53
+
54
+ A Studio workspace is the organization and project unit, and `resource` identifies the individual deployment, so a grant is scoped to exactly one database environment.
55
+
56
+ Access tokens are opaque references to a grant, not copies of it.
57
+ Nothing about the policy is embedded in the token, which is what makes the following true:
58
+
59
+ - Revoking a grant, or revoking the client's refresh token, stops the client immediately.
60
+ - Editing a grant changes what the client may do on its next request, without issuing a new credential.
61
+ - Changing the user's workspace roles or the workspace MCP policy changes what the client may do, again on its next request.
62
+
63
+ ## Effective authorization
64
+
65
+ The access delegated to an MCP client is the intersection of four things:
66
+
67
+ 1. The workspace `mcpPolicy`.
68
+ 2. The member's `mcpPolicy`.
69
+ 3. The member's Studio roles.
70
+ 4. The capabilities on the grant.
71
+
72
+ Every layer can only take access away.
73
+ A read-only user cannot delegate write access, a user capped at secondary reads cannot delegate primary reads, and a user capped at 5 seconds cannot delegate 30 seconds.
74
+ The authorization page only offers options that are valid reductions of what the user already has, and the server intersects the request again before storing it, so a tampered request cannot widen a grant.
75
+
76
+ Studio enforces the resolved policy per request:
77
+
78
+ - `write: false` downgrades the request's roles, so write actions are neither offered as MCP tools nor callable.
79
+ - `maxTimeMS` and `readPreference` travel as request params, set from the grant by trusted middleware, and every Studio action applies them to its MongoDB operations, including operations inside dashboard and document scripts.
80
+
81
+ The Studio action tree is built once, with one set of options, so these limits are per request rather than per mount.
82
+ They can only narrow what the host application already allows: `maxTimeMS` is capped by the `maxTimeMS` option when one is configured, and a read preference is resolved to whichever of the configured and requested values is more restrictive.
83
+ `maxTimeMS` and `readPreference` are also reserved MCP params, so they are not offered to MCP clients as tool inputs and a client cannot name them itself.
84
+
85
+ ## Configuration
86
+
87
+ The Mongoose Studio side needs a Pro API key and its own externally reachable URL, since the resource identifier is that URL.
88
+ Set `publicUrl` to the URL where Mongoose Studio is mounted:
89
+
90
+ ```javascript
91
+ app.use('/studio', await studio('/studio/api', mongoose, {
92
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
93
+ publicUrl: 'https://app.example.com/studio'
94
+ }));
95
+ ```
96
+
97
+ Pass `mcp: false` to serve Studio without an MCP endpoint at all.
98
+ Neither the endpoint nor its protected resource metadata is then mounted, so there is nothing for an MCP client to discover.
99
+ Without an API key, the MCP endpoint follows the normal localhost or `bindIp` restrictions and does not mount OAuth protected resource metadata.
100
+
101
+ Mongoose Studio registers that URL with the authorization server the first time a client asks for its protected resource metadata, which is how an OAuth `resource` parameter is mapped back to a workspace.
102
+ It advertises the configured mothership's origin in `authorization_servers`, keeping authentication with the same service Mongoose Studio already uses.
103
+
104
+ Browser based MCP clients, claude.ai among them, run discovery from the page rather than from a server.
105
+ Studio therefore serves the MCP endpoint and its metadata with CORS, and exposes `WWW-Authenticate` via `Access-Control-Expose-Headers`.
106
+ Without that last header a browser hides the challenge from the client, and the client cannot find the authorization server at all.
package/eslint.config.js CHANGED
@@ -13,6 +13,7 @@ module.exports = defineConfig([
13
13
  sourceType: 'commonjs',
14
14
  globals: {
15
15
  fetch: true,
16
+ URL: true,
16
17
  setTimeout: true,
17
18
  process: true,
18
19
  console: true,
package/eval/agent/run.js CHANGED
@@ -161,7 +161,7 @@ async function executeFinalCode(conn, trace) {
161
161
  if (!trace.finalCode) {
162
162
  return;
163
163
  }
164
- const sandbox = createSandbox({ db: conn });
164
+ const sandbox = createSandbox(conn, {});
165
165
  try {
166
166
  trace.output = await sandbox.runScript({ script: trace.finalCode });
167
167
  trace.logs = sandbox.getLogs();