@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/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
|
};
|