@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.
Files changed (81) hide show
  1. package/AGENTS.md +69 -2
  2. package/README.md +24 -0
  3. package/backend/actions/ChatMessage/executeScript.js +14 -6
  4. package/backend/actions/ChatThread/createChatMessage.js +7 -4
  5. package/backend/actions/ChatThread/getChatThread.js +7 -4
  6. package/backend/actions/ChatThread/listChatThreads.js +5 -2
  7. package/backend/actions/ChatThread/shareChatThread.js +5 -2
  8. package/backend/actions/ChatThread/streamChatMessage.js +7 -4
  9. package/backend/actions/ChatThread/toggleAgentMode.js +5 -2
  10. package/backend/actions/Dashboard/createDashboard.js +3 -0
  11. package/backend/actions/Dashboard/deleteDashboard.js +9 -3
  12. package/backend/actions/Dashboard/getDashboard.js +23 -11
  13. package/backend/actions/Dashboard/getDashboards.js +8 -2
  14. package/backend/actions/Dashboard/updateDashboard.js +8 -2
  15. package/backend/actions/Model/addField.js +8 -2
  16. package/backend/actions/Model/analyzeSchema.js +15 -5
  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 +8 -2
  20. package/backend/actions/Model/deleteDocuments.js +8 -2
  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 +14 -3
  24. package/backend/actions/Model/exportQueryResults.js +12 -2
  25. package/backend/actions/Model/getCollectionInfo.js +14 -4
  26. package/backend/actions/Model/getDocument.js +12 -2
  27. package/backend/actions/Model/getDocuments.js +13 -3
  28. package/backend/actions/Model/getDocumentsStream.js +13 -3
  29. package/backend/actions/Model/getEstimatedDocumentCounts.js +12 -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 +8 -2
  36. package/backend/actions/Model/updateDocuments.js +8 -2
  37. package/backend/actions/Model/validateDocument.js +12 -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 +5 -2
  43. package/backend/actions/Task/getTaskOverview.js +6 -3
  44. package/backend/actions/Task/getTasks.js +6 -3
  45. package/backend/actions/Task/getTasksOverTime.js +6 -3
  46. package/backend/actions/Task/rescheduleTask.js +5 -2
  47. package/backend/actions/Task/runTask.js +5 -2
  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/operationOptions.js +26 -0
  52. package/backend/helpers/readOperationOptions.js +31 -0
  53. package/backend/index.js +2 -0
  54. package/backend/integrations/mothership.js +28 -0
  55. package/backend/mcp.js +140 -0
  56. package/backend/netlify.js +5 -3
  57. package/backend/next.js +5 -3
  58. package/backend/sandbox/createSandbox.js +2 -2
  59. package/backend/sandbox/createScriptDb.js +40 -23
  60. package/backend/util/archetypeToZodSchema.js +74 -0
  61. package/docs/mcp-oauth.md +106 -0
  62. package/eslint.config.js +1 -0
  63. package/eval/agent/run.js +1 -1
  64. package/express.js +183 -41
  65. package/frontend/index.js +3 -0
  66. package/frontend/public/app.js +441 -48
  67. package/frontend/public/tw.css +8 -0
  68. package/frontend/src/api.js +22 -0
  69. package/frontend/src/index.js +9 -1
  70. package/frontend/src/mcp/mcp.html +55 -0
  71. package/frontend/src/mcp/mcp.js +27 -0
  72. package/frontend/src/navbar/navbar.html +8 -0
  73. package/frontend/src/navbar/navbar.js +3 -0
  74. package/frontend/src/routes.js +16 -0
  75. package/frontend/src/script/script.html +105 -0
  76. package/frontend/src/script/script.js +98 -0
  77. package/index.d.ts +10 -1
  78. package/nest.d.ts +3 -0
  79. package/package.json +5 -3
  80. package/vercel.d.ts +2 -0
  81. package/CLAUDE.md +0 -7
@@ -4,6 +4,19 @@ const omitNullish = require('../helpers/omitNullish');
4
4
 
5
5
  const wrappedCollections = new WeakSet();
6
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
+
7
20
  const collectionMethodOptionsIndex = new Map([
8
21
  ['aggregate', 1],
9
22
  ['bulkWrite', 1],
@@ -48,10 +61,13 @@ function createScriptDb(db, options = {}) {
48
61
  }
49
62
 
50
63
  let dryRunSession = null;
51
- const getSession = () => dryRunSession;
52
- const getMaxTimeMS = () => options.maxTimeMS;
53
- cloneModels(sourceConnection, scriptConnection, getSession, getMaxTimeMS);
54
- wrapCollectionAccessors(scriptConnection, getSession, getMaxTimeMS);
64
+ const getOperationOptions = () => ({
65
+ session: dryRunSession,
66
+ maxTimeMS: options.maxTimeMS,
67
+ readPreference: options.readPreference
68
+ });
69
+ cloneModels(sourceConnection, scriptConnection, getOperationOptions);
70
+ wrapCollectionAccessors(scriptConnection, getOperationOptions);
55
71
 
56
72
  const originalDebug = sourceConnection.options?.debug;
57
73
  scriptConnection.set('debug', function() {
@@ -79,14 +95,14 @@ function createScriptDb(db, options = {}) {
79
95
  };
80
96
  }
81
97
 
82
- function cloneModels(sourceConnection, scriptConnection, getSession, getMaxTimeMS) {
98
+ function cloneModels(sourceConnection, scriptConnection, getOperationOptions) {
83
99
  for (const Model of Object.values(sourceConnection.models ?? {})) {
84
100
  if (Model?.schema == null || typeof scriptConnection.model !== 'function') {
85
101
  continue;
86
102
  }
87
103
 
88
104
  const ClonedModel = scriptConnection.model(Model.modelName, Model.schema, getCollectionName(Model));
89
- wrapModelCollections(ClonedModel, getSession, getMaxTimeMS);
105
+ wrapModelCollections(ClonedModel, getOperationOptions);
90
106
  }
91
107
  }
92
108
 
@@ -94,13 +110,13 @@ function getCollectionName(Model) {
94
110
  return Model.collection?.collectionName ?? Model.collection?.name ?? Model.$__collection?.collectionName;
95
111
  }
96
112
 
97
- function wrapModelCollections(Model, getSession, getMaxTimeMS) {
113
+ function wrapModelCollections(Model, getOperationOptions) {
98
114
  const collection = Model?.collection ?? Model?.$__collection;
99
115
  if (collection == null) {
100
116
  return;
101
117
  }
102
118
 
103
- const wrappedCollection = wrapCollection(collection, getSession, getMaxTimeMS);
119
+ const wrappedCollection = wrapCollection(collection, getOperationOptions);
104
120
  Model.collection = wrappedCollection;
105
121
  Model.$__collection = wrappedCollection;
106
122
 
@@ -119,7 +135,7 @@ function setModelCollectionSymbols(target, collection) {
119
135
  }
120
136
  }
121
137
 
122
- function wrapCollectionAccessors(scriptConnection, getSession, getMaxTimeMS) {
138
+ function wrapCollectionAccessors(scriptConnection, getOperationOptions) {
123
139
  const wrappedCollectionCache = new WeakMap();
124
140
 
125
141
  const nativeDb = scriptConnection.db;
@@ -136,19 +152,19 @@ function wrapCollectionAccessors(scriptConnection, getSession, getMaxTimeMS) {
136
152
  collection.collection = originalDbCollection.call(nativeDb, collection.name);
137
153
  }
138
154
  }
139
- return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession, getMaxTimeMS);
155
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
140
156
  };
141
157
  }
142
158
 
143
159
  if (originalDbCollection != null) {
144
160
  nativeDb.collection = function() {
145
161
  const collection = originalDbCollection.apply(this, arguments);
146
- return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession, getMaxTimeMS);
162
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
147
163
  };
148
164
  }
149
165
  }
150
166
 
151
- function getOrCreateWrappedCollection(cache, collection, getSession, getMaxTimeMS) {
167
+ function getOrCreateWrappedCollection(cache, collection, getOperationOptions) {
152
168
  if (collection == null || (typeof collection !== 'object' && typeof collection !== 'function')) {
153
169
  return collection;
154
170
  }
@@ -158,12 +174,12 @@ function getOrCreateWrappedCollection(cache, collection, getSession, getMaxTimeM
158
174
  if (cache.has(collection)) {
159
175
  return cache.get(collection);
160
176
  }
161
- const wrapped = wrapCollection(collection, getSession, getMaxTimeMS);
177
+ const wrapped = wrapCollection(collection, getOperationOptions);
162
178
  cache.set(collection, wrapped);
163
179
  return wrapped;
164
180
  }
165
181
 
166
- function wrapCollection(collection, getSession, getMaxTimeMS) {
182
+ function wrapCollection(collection, getOperationOptions) {
167
183
  if (wrappedCollections.has(collection)) {
168
184
  return collection;
169
185
  }
@@ -176,20 +192,20 @@ function wrapCollection(collection, getSession, getMaxTimeMS) {
176
192
  }
177
193
 
178
194
  wrapped[methodName] = function() {
179
- const args = addOperationOptions(
180
- Array.from(arguments),
181
- optionsIndex,
182
- getSession(),
183
- getMaxTimeMS()
184
- );
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
+ });
185
201
  return method.apply(collection, args);
186
202
  };
187
203
  }
188
204
  return wrapped;
189
205
  }
190
206
 
191
- function addOperationOptions(args, optionsIndex, session, maxTimeMS) {
192
- if (session == null && maxTimeMS == null) {
207
+ function addOperationOptions(args, optionsIndex, { session, maxTimeMS, readPreference }) {
208
+ if (session == null && maxTimeMS == null && readPreference == null) {
193
209
  return args;
194
210
  }
195
211
 
@@ -203,7 +219,7 @@ function addOperationOptions(args, optionsIndex, session, maxTimeMS) {
203
219
  if (session != null) {
204
220
  throw new Error('Cannot run dry run on script where options arg is a non-object');
205
221
  }
206
- throw new Error('Cannot apply maxTimeMS to script where options arg is a non-object');
222
+ throw new Error('Cannot apply Studio operation options to script where options arg is a non-object');
207
223
  }
208
224
 
209
225
  if (session != null && options?.session != null) {
@@ -213,6 +229,7 @@ function addOperationOptions(args, optionsIndex, session, maxTimeMS) {
213
229
  args[optionsIndex] = omitNullish({
214
230
  ...(options ?? {}),
215
231
  maxTimeMS: maxTimeMS ?? options?.maxTimeMS,
232
+ readPreference: readPreference ?? options?.readPreference,
216
233
  session: session ?? options?.session
217
234
  });
218
235
  return args;
@@ -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();
package/express.js CHANGED
@@ -3,6 +3,8 @@
3
3
  const Backend = require('./backend');
4
4
  const express = require('express');
5
5
  const frontend = require('./frontend');
6
+ const mcp = require('./backend/mcp');
7
+ const mothership = require('./backend/integrations/mothership');
6
8
  const isBindIPConnection = require('./backend/helpers/isBindIPConnection');
7
9
  const isLocalhostConnection = require('./backend/helpers/isLocalhostConnection');
8
10
  const normalizeBindIPOption = require('./backend/helpers/normalizeBindIPOption');
@@ -10,10 +12,14 @@ const { toRoute, objectRouter } = require('extrovert');
10
12
  const { defaultMothershipURL } = require('./constants');
11
13
 
12
14
  const jsonParser = express.json();
15
+ const MCP_ACCESS_TOKEN_PREFIX = 'mcp_at_';
13
16
 
14
17
  module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options) {
15
18
  const router = express.Router();
16
19
  options = options ? { changeStream: true, ...options } : { changeStream: true };
20
+ if (options.mcp !== false && options.apiKey && !options.publicUrl) {
21
+ throw new Error('Mongoose Studio requires the publicUrl option when MCP is enabled with an API key');
22
+ }
17
23
  const hasBindIpOption = Object.prototype.hasOwnProperty.call(options, 'bindIp');
18
24
  const bindIp = normalizeBindIPOption(options.bindIp);
19
25
 
@@ -38,6 +44,7 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
38
44
  })
39
45
  .then(res => res.json()));
40
46
  }
47
+ options.workspace = workspace;
41
48
 
42
49
  if (!workspace && bindIp !== null) {
43
50
  router.use((req, res, next) => {
@@ -54,49 +61,56 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
54
61
  const backend = Backend(conn, options.studioConnection, options);
55
62
  delete backend.services;
56
63
 
57
- router.use(
58
- '/api',
59
- function authorize(req, res, next) {
60
- if (!workspace) {
61
- next();
62
- return;
64
+ function authorizeRequest(req, res, next) {
65
+ if (!workspace) {
66
+ next();
67
+ return;
68
+ }
69
+ const authorizationHeader = req.headers.authorization;
70
+ if (!authorizationHeader) {
71
+ res.setHeader('WWW-Authenticate', 'Bearer');
72
+ return res.status(401).json({ message: 'Not authorized' });
73
+ }
74
+ const authorization = authorizationHeader.replace(/^Bearer\s+/i, '');
75
+ const params = {
76
+ method: 'POST',
77
+ body: JSON.stringify({ workspaceId: workspace._id }),
78
+ headers: {
79
+ 'Authorization': authorization,
80
+ 'Content-Type': 'application/json'
63
81
  }
64
- const authorization = req.headers.authorization;
65
- const params = {
66
- method: 'POST',
67
- body: JSON.stringify({ workspaceId: workspace._id }),
68
- headers: {
69
- 'Authorization': authorization,
70
- 'Content-Type': 'application/json'
82
+ };
83
+ fetch(`${mothershipUrl}/me`, params)
84
+ .then(response => {
85
+ if (response.status < 200 || response.status >= 400) {
86
+ return response.json().then(data => {
87
+ throw new Error(`Mongoose Studio API Key Error ${response.status}: ${require('util').inspect(data)}`);
88
+ });
71
89
  }
72
- };
73
- fetch(`${mothershipUrl}/me`, params)
74
- .then(response => {
75
- if (response.status < 200 || response.status >= 400) {
76
- return response.json().then(data => {
77
- throw new Error(`Mongoose Studio API Key Error ${response.status}: ${require('util').inspect(data)}`);
78
- });
79
- }
80
- return response;
81
- })
82
- .then(res => res.json())
83
- .then(({ user, roles }) => {
84
- if (!user || !roles) {
85
- return res.status(403).json({ message: 'Not authorized' });
86
- }
87
- req._internals = req._internals || {};
88
- req._internals.authorization = authorization;
89
- req._internals.initiatedById = user._id;
90
- req._internals.roles = roles;
91
- req._internals.$workspaceId = workspace._id;
92
- req._internals.initiatedBy = user;
93
-
94
- next();
95
- })
96
- .catch(err => {
97
- return res.status(500).json({ message: err.message });
98
- });
99
- },
90
+ return response;
91
+ })
92
+ .then(res => res.json())
93
+ .then(({ user, roles }) => {
94
+ if (!user || !roles) {
95
+ return res.status(403).json({ message: 'Not authorized' });
96
+ }
97
+ req._internals = req._internals || {};
98
+ req._internals.authorization = authorization;
99
+ req._internals.initiatedById = user._id;
100
+ req._internals.roles = roles;
101
+ req._internals.$workspaceId = workspace._id;
102
+ req._internals.initiatedBy = user;
103
+
104
+ next();
105
+ })
106
+ .catch(err => {
107
+ return res.status(500).json({ message: err.message });
108
+ });
109
+ }
110
+
111
+ router.use(
112
+ '/api',
113
+ authorizeRequest,
100
114
  function parseJson(req, res, next) {
101
115
  if (req.body !== undefined || req.readable === false) {
102
116
  return next();
@@ -106,6 +120,110 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
106
120
  objectRouter(backend, toRoute)
107
121
  );
108
122
 
123
+ // MCP clients (ChatGPT, Claude, ...) authenticate with OAuth access tokens
124
+ // issued by the mothership. Mongoose Studio is the protected resource: it publishes the
125
+ // metadata that points clients at the authorization server, and resolves each
126
+ // token to its current grant before handling the request.
127
+ const mcpEnabled = options.mcp !== false;
128
+ const registeredMCPResources = new Set();
129
+ const mcpAuthorizationServerUrl = new URL(mothershipUrl).origin;
130
+ const mcpResource = workspace ? canonicalize(`${options.publicUrl.replace(/\/+$/, '')}/mcp`) : null;
131
+
132
+ async function registerMCPResource(resource) {
133
+ if (registeredMCPResources.has(resource)) {
134
+ return;
135
+ }
136
+ try {
137
+ await mothership.Workspace.registerMCPResource({ apiKey: options.apiKey, resource }, options);
138
+ registeredMCPResources.add(resource);
139
+ } catch (err) {
140
+ // Not fatal: metadata is still worth serving, and the next request retries.
141
+ console.warn(`[MONGOOSE STUDIO] Could not register MCP resource ${resource}: ${err.message}`);
142
+ }
143
+ }
144
+
145
+ function mcpChallenge({ error, description } = {}) {
146
+ // A client may go straight to the authorization server without requesting
147
+ // metadata first, so register the resource when sending the challenge too.
148
+ registerMCPResource(mcpResource).catch(() => {});
149
+ const metadataUrl = `${mcpResource.replace(/\/mcp$/, '')}/.well-known/oauth-protected-resource`;
150
+ const parts = [`Bearer resource_metadata="${metadataUrl}"`];
151
+ if (error) {
152
+ parts.push(`error="${error}"`);
153
+ }
154
+ if (description) {
155
+ parts.push(`error_description="${`${description}`.replace(/"/g, '')}"`);
156
+ }
157
+ return parts.join(', ');
158
+ }
159
+
160
+ async function serveProtectedResourceMetadata(req, res, next) {
161
+ try {
162
+ await registerMCPResource(mcpResource);
163
+ res.setHeader('Cache-Control', 'no-store');
164
+ res.json({
165
+ resource: mcpResource,
166
+ authorization_servers: [mcpAuthorizationServerUrl],
167
+ scopes_supported: ['mcp'],
168
+ bearer_methods_supported: ['header'],
169
+ resource_documentation: 'https://mongoosestudio.app/docs/'
170
+ });
171
+ } catch (err) {
172
+ next(err);
173
+ }
174
+ }
175
+
176
+ if (mcpEnabled && workspace) {
177
+ // Browser based MCP clients discover and connect cross-origin, so both the
178
+ // metadata and the MCP endpoint itself need CORS, including a preflight.
179
+ router.use('/.well-known/oauth-protected-resource', mcpCors);
180
+ router.get('/.well-known/oauth-protected-resource', serveProtectedResourceMetadata);
181
+ router.get('/.well-known/oauth-protected-resource/mcp', serveProtectedResourceMetadata);
182
+ }
183
+ if (mcpEnabled) {
184
+ router.use('/mcp', mcpCors, authorizeMCPRequest, jsonParser, mcp(backend));
185
+ }
186
+
187
+ function authorizeMCPRequest(req, res, next) {
188
+ if (!workspace) {
189
+ return authorizeRequest(req, res, next);
190
+ }
191
+ const token = `${req.headers.authorization || ''}`.replace(/^Bearer\s+/i, '').trim();
192
+ if (!token) {
193
+ res.setHeader('WWW-Authenticate', mcpChallenge());
194
+ return res.status(401).json({ message: 'Not authorized' });
195
+ }
196
+ if (!token.startsWith(MCP_ACCESS_TOKEN_PREFIX)) {
197
+ // A Studio session token, for example the Studio UI talking to its own
198
+ // MCP endpoint. Fall back to the regular Studio authorization path.
199
+ return authorizeRequest(req, res, next);
200
+ }
201
+
202
+ mothership.MCPOAuthToken.introspectMCPAccessToken({ apiKey: options.apiKey, token, resource: mcpResource }, options).
203
+ then(({ user, permissions }) => {
204
+ req._internals = req._internals || {};
205
+ req._internals.authorization = token;
206
+ req._internals.initiatedById = user._id;
207
+ req._internals.roles = permissions.roles;
208
+ req._internals.$workspaceId = workspace._id;
209
+ req._internals.initiatedBy = user;
210
+ // The limits from the grant, applied to every operation this request
211
+ // runs. `maxTimeMS` is still capped by the host application's own
212
+ // `maxTimeMS` option, and a read preference can only be narrowed.
213
+ req._internals.maxTimeMS = permissions.maxTimeMS;
214
+ req._internals.readPreference = permissions.readPreference;
215
+
216
+ next();
217
+ }).
218
+ catch(err => {
219
+ if (err.status === 401 || err.status === 403) {
220
+ res.setHeader('WWW-Authenticate', mcpChallenge({ error: 'invalid_token', description: err.message }));
221
+ return res.status(401).json({ message: err.message });
222
+ }
223
+ return res.status(500).json({ message: err.message });
224
+ });
225
+ }
226
+
109
227
  const { config } = await frontend(apiUrl, false, options, workspace);
110
228
  config.enableTaskVisualizer = options.enableTaskVisualizer;
111
229
  router.get('/config.js', function (req, res) {
@@ -121,3 +239,27 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
121
239
 
122
240
  return router;
123
241
  }
242
+
243
+ function mcpCors(req, res, next) {
244
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
245
+ res.setHeader('Vary', 'Origin');
246
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
247
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Protocol-Version, Mcp-Session-Id, Last-Event-ID');
248
+ res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version');
249
+ res.setHeader('Access-Control-Max-Age', '86400');
250
+
251
+ if (req.method === 'OPTIONS') {
252
+ return res.status(204).end();
253
+ }
254
+ next();
255
+ }
256
+
257
+ function canonicalize(resource) {
258
+ const url = new URL(resource);
259
+ url.hash = '';
260
+ url.search = '';
261
+ if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
262
+ url.pathname = url.pathname.replace(/\/+$/, '');
263
+ }
264
+ return url.toString();
265
+ }
package/frontend/index.js CHANGED
@@ -32,9 +32,12 @@ module.exports = async function frontend(apiUrl, isLambda, options, workspace) {
32
32
  }
33
33
 
34
34
  const { apiKey, ...workspaceData } = workspace || {};
35
+ const workspaceBaseUrl = workspace?.baseUrl?.replace(/\/+$/, '');
35
36
  const config = {
36
37
  baseURL: apiUrl,
37
38
  isLambda,
39
+ mcp: options?.mcp !== false,
40
+ mcpUrl: workspaceBaseUrl ? `${workspaceBaseUrl}/mcp` : null,
38
41
  mothershipUrl: apiKey ? mothershipUrl : null,
39
42
  workspace: workspaceData
40
43
  };