@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.
- package/AGENTS.md +74 -0
- package/README.md +27 -0
- package/backend/actions/ChatMessage/executeScript.js +15 -5
- package/backend/actions/ChatThread/createChatMessage.js +7 -2
- package/backend/actions/ChatThread/getChatThread.js +8 -2
- package/backend/actions/ChatThread/listChatThreads.js +6 -1
- package/backend/actions/ChatThread/shareChatThread.js +8 -2
- package/backend/actions/ChatThread/streamChatMessage.js +7 -2
- package/backend/actions/ChatThread/toggleAgentMode.js +8 -2
- package/backend/actions/Dashboard/createDashboard.js +3 -0
- package/backend/actions/Dashboard/deleteDashboard.js +13 -3
- package/backend/actions/Dashboard/getDashboard.js +33 -10
- package/backend/actions/Dashboard/getDashboards.js +9 -1
- package/backend/actions/Dashboard/updateDashboard.js +9 -2
- package/backend/actions/Model/addField.js +9 -2
- package/backend/actions/Model/analyzeSchema.js +16 -4
- package/backend/actions/Model/createChatMessage.js +3 -0
- package/backend/actions/Model/createDocument.js +3 -0
- package/backend/actions/Model/deleteDocument.js +9 -3
- package/backend/actions/Model/deleteDocuments.js +9 -1
- package/backend/actions/Model/dropCollection.js +3 -0
- package/backend/actions/Model/dropIndex.js +3 -0
- package/backend/actions/Model/executeDocumentScript.js +33 -26
- package/backend/actions/Model/exportQueryResults.js +13 -1
- package/backend/actions/Model/getCollectionInfo.js +15 -3
- package/backend/actions/Model/getDocument.js +13 -2
- package/backend/actions/Model/getDocuments.js +25 -5
- package/backend/actions/Model/getDocumentsStream.js +38 -18
- package/backend/actions/Model/getEstimatedDocumentCounts.js +15 -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 +10 -2
- package/backend/actions/Model/updateDocuments.js +10 -2
- package/backend/actions/Model/validateDocument.js +15 -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 +9 -3
- package/backend/actions/Task/getTaskOverview.js +7 -3
- package/backend/actions/Task/getTasks.js +7 -3
- package/backend/actions/Task/getTasksOverTime.js +7 -3
- package/backend/actions/Task/rescheduleTask.js +9 -3
- package/backend/actions/Task/runTask.js +9 -3
- package/backend/actions/index.js +1 -0
- package/backend/authorize.js +6 -1
- package/backend/db/scriptSchema.js +29 -0
- package/backend/helpers/omitNullish.js +5 -0
- package/backend/helpers/operationOptions.js +26 -0
- package/backend/helpers/readOperationOptions.js +31 -0
- package/backend/index.js +6 -4
- 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 +60 -20
- 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 +740 -124
- package/frontend/public/tw.css +76 -0
- package/frontend/src/api.js +27 -1
- 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/models/models.html +29 -8
- package/frontend/src/models/models.js +30 -2
- 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/frontend/src/tooltip/tooltip.html +28 -0
- package/frontend/src/tooltip/tooltip.js +94 -0
- package/index.d.ts +10 -1
- package/local.js +2 -1
- package/nest.d.ts +4 -0
- package/package.json +5 -3
- 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
|
+
}
|
package/backend/netlify.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
const
|
|
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
|
|
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
|
-
|
|
7
|
+
options = { ...options };
|
|
8
|
+
const backend = Backend(conn, options.studioConnection, options);
|
|
8
9
|
|
|
9
|
-
const mothershipUrl = options
|
|
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
|
|
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({
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
162
|
+
return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getOperationOptions);
|
|
136
163
|
};
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
166
|
|
|
140
|
-
function getOrCreateWrappedCollection(cache, collection,
|
|
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,
|
|
177
|
+
const wrapped = wrapCollection(collection, getOperationOptions);
|
|
151
178
|
cache.set(collection, wrapped);
|
|
152
179
|
return wrapped;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
|
-
function wrapCollection(collection,
|
|
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
|
|
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
|
|
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
|
-
|
|
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] =
|
|
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
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();
|