@mongoosejs/studio 0.5.14 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +69 -2
- package/README.md +24 -0
- package/backend/actions/ChatMessage/executeScript.js +14 -6
- package/backend/actions/ChatThread/createChatMessage.js +7 -4
- package/backend/actions/ChatThread/getChatThread.js +7 -4
- package/backend/actions/ChatThread/listChatThreads.js +5 -2
- package/backend/actions/ChatThread/shareChatThread.js +5 -2
- package/backend/actions/ChatThread/streamChatMessage.js +7 -4
- package/backend/actions/ChatThread/toggleAgentMode.js +5 -2
- package/backend/actions/Dashboard/createDashboard.js +3 -0
- package/backend/actions/Dashboard/deleteDashboard.js +9 -3
- package/backend/actions/Dashboard/getDashboard.js +23 -11
- package/backend/actions/Dashboard/getDashboards.js +8 -2
- package/backend/actions/Dashboard/updateDashboard.js +8 -2
- package/backend/actions/Model/addField.js +8 -2
- package/backend/actions/Model/analyzeSchema.js +15 -5
- package/backend/actions/Model/createChatMessage.js +3 -0
- package/backend/actions/Model/createDocument.js +3 -0
- package/backend/actions/Model/deleteDocument.js +8 -2
- package/backend/actions/Model/deleteDocuments.js +8 -2
- package/backend/actions/Model/dropCollection.js +3 -0
- package/backend/actions/Model/dropIndex.js +3 -0
- package/backend/actions/Model/executeDocumentScript.js +14 -3
- package/backend/actions/Model/exportQueryResults.js +12 -2
- package/backend/actions/Model/getCollectionInfo.js +14 -4
- package/backend/actions/Model/getDocument.js +12 -2
- package/backend/actions/Model/getDocuments.js +13 -3
- package/backend/actions/Model/getDocumentsStream.js +13 -3
- package/backend/actions/Model/getEstimatedDocumentCounts.js +12 -2
- package/backend/actions/Model/getIndexes.js +3 -0
- package/backend/actions/Model/getSuggestedProjection.js +3 -0
- package/backend/actions/Model/listModels.js +3 -0
- package/backend/actions/Model/streamChatMessage.js +3 -0
- package/backend/actions/Model/streamDocumentChanges.js +3 -0
- package/backend/actions/Model/updateDocument.js +8 -2
- package/backend/actions/Model/updateDocuments.js +8 -2
- package/backend/actions/Model/validateDocument.js +12 -2
- package/backend/actions/Script/createScript.js +36 -0
- package/backend/actions/Script/executeScript.js +89 -0
- package/backend/actions/Script/getScript.js +43 -0
- package/backend/actions/Script/index.js +5 -0
- package/backend/actions/Task/cancelTask.js +5 -2
- package/backend/actions/Task/getTaskOverview.js +6 -3
- package/backend/actions/Task/getTasks.js +6 -3
- package/backend/actions/Task/getTasksOverTime.js +6 -3
- package/backend/actions/Task/rescheduleTask.js +5 -2
- package/backend/actions/Task/runTask.js +5 -2
- package/backend/actions/index.js +1 -0
- package/backend/authorize.js +6 -1
- package/backend/db/scriptSchema.js +29 -0
- package/backend/helpers/operationOptions.js +26 -0
- package/backend/helpers/readOperationOptions.js +31 -0
- package/backend/index.js +2 -0
- package/backend/integrations/mothership.js +28 -0
- package/backend/mcp.js +140 -0
- package/backend/netlify.js +5 -3
- package/backend/next.js +5 -3
- package/backend/sandbox/createSandbox.js +2 -2
- package/backend/sandbox/createScriptDb.js +40 -23
- package/backend/util/archetypeToZodSchema.js +74 -0
- package/docs/mcp-oauth.md +106 -0
- package/eslint.config.js +1 -0
- package/eval/agent/run.js +1 -1
- package/express.js +183 -41
- package/frontend/index.js +3 -0
- package/frontend/public/app.js +441 -48
- package/frontend/public/tw.css +8 -0
- package/frontend/src/api.js +22 -0
- package/frontend/src/index.js +9 -1
- package/frontend/src/mcp/mcp.html +55 -0
- package/frontend/src/mcp/mcp.js +27 -0
- package/frontend/src/navbar/navbar.html +8 -0
- package/frontend/src/navbar/navbar.js +3 -0
- package/frontend/src/routes.js +16 -0
- package/frontend/src/script/script.html +105 -0
- package/frontend/src/script/script.js +98 -0
- package/index.d.ts +10 -1
- package/nest.d.ts +3 -0
- package/package.json +5 -3
- package/vercel.d.ts +2 -0
- package/CLAUDE.md +0 -7
|
@@ -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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
162
|
+
return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
|
|
147
163
|
};
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
|
|
151
|
-
function getOrCreateWrappedCollection(cache, collection,
|
|
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,
|
|
177
|
+
const wrapped = wrapCollection(collection, getOperationOptions);
|
|
162
178
|
cache.set(collection, wrapped);
|
|
163
179
|
return wrapped;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
function wrapCollection(collection,
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
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(
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
};
|