@mongoosejs/studio 0.6.0 → 0.7.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/CHANGELOG.md ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ ## 0.7.0
4
+
5
+ ### MCP support for Next.js and Vercel
6
+
7
+ Mongoose Studio's MCP endpoint now works on Next.js and standalone Vercel Functions, not only Express.
8
+ MCP uses the same defaults as Express: it is enabled by default without an `apiKey`, and with an `apiKey` it requires `mcp: true` and `publicUrl` and uses OAuth.
9
+
10
+ For Next.js, rename `pages/api/studio.js` to the optional catch-all route `pages/api/studio/[[...studioRoute]].js`.
11
+ The same `vercelAPIRouteHandler()` handles Studio actions at `/api/studio`, MCP at `/api/studio/mcp`, and OAuth metadata at `/api/studio/.well-known/oauth-protected-resource`.
12
+ `withMongooseStudio()` now accepts a second `options` argument with `mcp`, `apiKey`, and `publicUrl`, and adds rewrites so MCP clients connect to `/studio/mcp`:
13
+
14
+ ```javascript
15
+ // next.config.js
16
+ export default withMongooseStudio({ reactStrictMode: true }, {
17
+ mcp: true,
18
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
19
+ });
20
+
21
+ // pages/api/studio/[[...studioRoute]].js
22
+ export default vercelAPIRouteHandler(db, {
23
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
24
+ mcp: true,
25
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
26
+ });
27
+ ```
28
+
29
+ For standalone Vercel Functions, keep `api/studio.js` for Studio actions and add `api/studio-mcp.js` using the new `vercelMCPRouteHandler()`:
30
+
31
+ ```javascript
32
+ // api/studio-mcp.js
33
+ const { vercelMCPRouteHandler } = require('@mongoosejs/studio/vercel');
34
+
35
+ module.exports = vercelMCPRouteHandler(mongoose, {
36
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
37
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
38
+ });
39
+ ```
40
+
41
+ With an `apiKey` and `mcp: true`, building the frontend with `frontend()` now writes the OAuth protected resource metadata to `frontend/public/.well-known/oauth-protected-resource` and registers `/api/studio-mcp` with the authorization server.
42
+ Copy the frontend only after the `frontend()` promise resolves, and make sure the copy includes hidden directories such as `.well-known`.
43
+ See the README for the full Vercel setup.
44
+
45
+ ### Breaking: Vercel and Next.js handlers without an API key are limited to localhost
46
+
47
+ `vercelAPIRouteHandler()` now applies the same `bindIp` restriction as the Express integration.
48
+ Without an `apiKey`, Studio API actions and MCP requests are only accepted from localhost by default, and other clients get a 403.
49
+ On Vercel, the client IP comes from the `x-vercel-forwarded-for` header, so a deployed handler without an `apiKey` rejects every request unless you configure `bindIp`.
50
+
51
+ Set `bindIp` to an IP address or an array of IP addresses to allow specific clients, or set `bindIp: null` to restore the previous behavior of accepting requests from any IP:
52
+
53
+ ```javascript
54
+ const handler = vercelAPIRouteHandler(db, {
55
+ bindIp: null
56
+ });
57
+ ```
58
+
59
+ Handlers configured with an `apiKey` are unaffected.
60
+
61
+ ## 0.6.1
62
+
63
+ ### Safer MCP defaults with API keys
64
+
65
+ MCP now defaults to disabled when Mongoose Studio is configured with an `apiKey`. Existing API-key integrations therefore continue to start without requiring additional MCP configuration.
66
+
67
+ To enable OAuth-protected MCP, set `mcp: true` and provide `publicUrl` with the publicly reachable base URL where Mongoose Studio is mounted:
68
+
69
+ ```javascript
70
+ app.use('/studio', await studio('/studio/api', mongoose, {
71
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
72
+ mcp: true,
73
+ publicUrl: 'https://myproduct.app/studio'
74
+ }));
75
+ ```
76
+
77
+ Mongoose Studio only requires `publicUrl` when both `apiKey` and `mcp: true` are set. Without an API key, MCP remains enabled by default and protected by `bindIp`.
78
+
79
+ ## 0.6.0
80
+
81
+ ### MCP support
82
+
83
+ Mongoose Studio now serves an MCP endpoint at `<mount>/mcp` using the same access controls as the rest of Mongoose Studio.
84
+
85
+ - MCP is enabled by default. Without an API key, access is protected by `bindIp`, so only clients running on the same machine can connect by default.
86
+ - Set `mcp: false` to disable the MCP endpoint and its OAuth discovery metadata.
87
+ - With an `apiKey`, MCP uses OAuth and requires `publicUrl` containing the publicly reachable base URL where Mongoose Studio is mounted, such as `https://myproduct.app/studio`.
88
+
89
+ ```javascript
90
+ app.use('/studio', await studio('/studio/api', mongoose, {
91
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
92
+ publicUrl: 'https://myproduct.app/studio'
93
+ }));
94
+ ```
95
+
96
+ Mongoose Studio throws an error during setup when MCP is enabled with an `apiKey` but `publicUrl` is missing.
package/README.md CHANGED
@@ -56,23 +56,24 @@ app.use('/studio', await studio('/studio/api', mongoose, {
56
56
  }));
57
57
  ```
58
58
 
59
- The Express integration also exposes a Streamable HTTP MCP endpoint at `/studio/mcp`.
60
- Set `mcp: false` to turn it off, along with the OAuth protected resource metadata that advertises it.
59
+ Without an API key, the Express integration exposes a Streamable HTTP MCP endpoint at `/studio/mcp` by default.
60
+ Set `mcp: false` to turn it off.
61
61
  It provides authorized Dashboard and Model actions, plus `Script.createScript`, as MCP tools.
62
62
  With a Pro API key, pass the logged-in Studio access token in the `Authorization` header (either directly or as a bearer token); tools receive that user's roles and permissions.
63
- Without workspace authentication, the MCP endpoint uses the same localhost and `bindIp` restrictions as the rest of Studio.
63
+ Without workspace authentication, the MCP endpoint uses the same localhost and `bindIp` restrictions as the rest of Mongoose Studio.
64
64
 
65
65
  #### Connecting ChatGPT or Claude with OAuth
66
66
 
67
- With a Pro API key, `/studio/mcp` is also an OAuth 2.1 protected resource, so MCP clients such as ChatGPT and Claude can connect to it directly.
67
+ With a Pro API key, MCP is disabled by default. Set `mcp: true` to expose `/studio/mcp` as an OAuth 2.1 protected resource so MCP clients such as ChatGPT and Claude can connect to it directly.
68
68
  Point the client at your `/studio/mcp` URL and it will discover the Mongoose Studio authorization server, walk the user through signing in and approving access, and receive a short-lived access token.
69
69
  Users review and revoke these connections from their Mongoose Studio account page, and the access an AI client gets can never exceed the access the user who approved it already has.
70
70
 
71
71
  Mongoose Studio advertises itself to MCP clients using its externally reachable URL.
72
- When MCP and a Pro API key are enabled, set `publicUrl` to the URL where Mongoose Studio is mounted:
72
+ When enabling MCP with a Pro API key, also set `publicUrl` to the URL where Mongoose Studio is mounted:
73
73
 
74
74
  ```javascript
75
75
  opts.publicUrl = 'https://app.example.com/studio';
76
+ opts.mcp = true;
76
77
  ```
77
78
 
78
79
  Mongoose Studio finds the authorization server through the mothership it is already configured to use, so it needs no separate authorization server configuration.
@@ -90,28 +91,109 @@ import withMongooseStudio from '@mongoosejs/studio/next';
90
91
  export default withMongooseStudio({
91
92
  // Your Next.js config here
92
93
  reactStrictMode: true,
94
+ }, {
95
+ mcp: true,
96
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
93
97
  });
94
98
  ```
95
99
 
96
- Then, add `pages/api/studio.js` to your Next.js project to host the Mongoose Studio API:
100
+ Add `pages/api/studio/[[...studioRoute]].js` to your Next.js project to host
101
+ both the Mongoose Studio API and MCP. If you already have
102
+ `pages/api/studio.js`, move its handler into the new file and remove the old
103
+ file. The optional catch-all keeps `/api/studio` working for existing Studio
104
+ actions and also accepts `/api/studio/mcp` and the OAuth metadata path.
97
105
 
98
106
  ```javascript
107
+ // pages/api/studio/[[...studioRoute]].js
99
108
  // Make sure to import the database connection
100
- import db from '../../src/db';
101
- import studio from '@mongoosejs/studio/backend/next';
109
+ import db from '../../../src/db';
110
+ import { vercelAPIRouteHandler } from '@mongoosejs/studio/vercel';
102
111
 
103
- const handler = studio(
112
+ const handler = vercelAPIRouteHandler(
104
113
  db, // Mongoose connection or Mongoose global. Or null to use `import mongoose`.
105
114
  {
106
115
  apiKey: process.env.MONGOOSE_STUDIO_API_KEY, // optional
107
116
  connection: db, // Optional: Connection or Mongoose global. If omitted, will use `import mongoose`
108
117
  connectToDB: async () => { /* connection logic here */ }, // Optional: if you need to call a function to connect to the database put it here
118
+ mcp: true, // Also enabled by default without an API key
119
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL // Required with an API key; e.g. https://app.example.com/studio
109
120
  }
110
121
  );
111
122
 
112
123
  export default handler;
113
124
  ```
114
125
 
126
+ With `mcp: true`, the same API function handles Streamable HTTP
127
+ at `/studio/mcp`. With an API key, it also serves OAuth discovery at
128
+ `/studio/.well-known/oauth-protected-resource`. `withMongooseStudio` installs
129
+ rewrites from those public URLs to the catch-all API route and shows
130
+ `/studio/mcp` in the frontend. No additional API function or `mcpUrl` setting
131
+ is needed. The rewrites are needed to keep the public URL at `/studio/mcp`;
132
+ the catch-all file itself lives under `/api/studio`. A Pro API key is required
133
+ for OAuth-based Vercel MCP. Without an API key, MCP is enabled by default and
134
+ is limited to localhost unless `bindIp` is configured. Set `bindIp` to an IP
135
+ address or an array of IP addresses to permit other clients; set it to `null`
136
+ to allow access from any IP. The same restriction applies to Studio API actions.
137
+ With an API key, set `publicUrl` to the externally reachable `/studio` URL. The OAuth resource
138
+ URL is derived from it, even if the workspace's `baseUrl` differs.
139
+
140
+ The handler is stateless and returns a JSON response for each POST. No Redis,
141
+ sticky sessions, or long-lived SSE connection is needed. Tool calls still count
142
+ against Vercel Function duration and payload limits, so set an appropriate
143
+ `maxDuration` for longer database operations and keep results reasonably sized.
144
+
145
+ ### Standalone Vercel Functions
146
+
147
+ If your project uses `api/studio.js` rather than Next.js, keep it for Studio
148
+ actions and add a separate `api/studio-mcp.js` function for MCP. Next.js
149
+ optional catch-all filenames such as `[[...studioRoute]].js` do not apply to
150
+ standalone Vercel Functions:
151
+
152
+ ```javascript
153
+ // api/studio-mcp.js
154
+ // Make sure to import the database connection
155
+ import db from '../../../src/db';
156
+ const { vercelMCPRouteHandler } = require('@mongoosejs/studio/vercel');
157
+
158
+ module.exports = vercelMCPRouteHandler(db, {
159
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
160
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
161
+ });
162
+ ```
163
+
164
+ Set `MONGOOSE_STUDIO_PUBLIC_URL` to the externally reachable Studio URL, for
165
+ example `https://app.example.com/studio`, when using an API key. Without an
166
+ API key, MCP is enabled by default and follows the same localhost and `bindIp`
167
+ restriction described above. Connect MCP clients to `/api/studio-mcp`; no
168
+ rewrite is required.
169
+
170
+ For OAuth, the protected resource metadata is generated in the Studio frontend
171
+ by the same `frontend()` call used to write `config.js`. It registers the MCP
172
+ resource before the returned promise resolves, so copy the frontend after
173
+ that promise resolves. Existing `publicUrl` fallbacks can stay as they are;
174
+ the copy source must include hidden files:
175
+
176
+ ```javascript
177
+ const { execSync } = require('child_process');
178
+ const opts = {
179
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
180
+ mcp: true,
181
+ publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
182
+ };
183
+ require('@mongoosejs/studio/frontend')('/api/studio', true, opts).then(() => {
184
+ execSync('mkdir -p ./public/studio && cp -r ./node_modules/@mongoosejs/studio/frontend/public/. ./public/studio/');
185
+ });
186
+ ```
187
+
188
+ The `/.` source copies hidden directories such as `.well-known`; a `/*` source
189
+ does not. This places `public/studio/.well-known/oauth-protected-resource`
190
+ and registers `/api/studio-mcp` with the authorization server. The metadata is
191
+ needed only with an API key. Set `Content-Type: application/json` and
192
+ `Access-Control-Allow-Origin: *` for that static path, as in the
193
+ [standalone Vercel example](development/movies-vercel/vercel.json). Its
194
+ [build script](development/movies-vercel/build.js) shows the complete asset
195
+ copy and metadata generation sequence.
196
+
115
197
  ### Nest.js
116
198
 
117
199
  Add `MongooseStudioModule` to your app module as follows.
package/backend/next.js CHANGED
@@ -1,17 +1,41 @@
1
1
  'use strict';
2
2
 
3
3
  const Backend = require('./');
4
+ const vercelMCP = require('./vercel/vercelMCP');
5
+ const checkVercelBindIp = require('./vercel/checkBindIp');
4
6
  const { defaultMothershipURL } = require('../constants');
5
7
 
6
8
  module.exports = function next(conn, options) {
7
9
  options = { ...options };
10
+ options.mcp = options.mcp ?? !options.apiKey;
11
+ const checkRequest = checkVercelBindIp(options);
8
12
  const backend = Backend(conn, options.studioConnection, options);
13
+ const mcpHandlers = options.mcp ? vercelMCP(conn, options, backend) : null;
9
14
 
10
15
  const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
11
16
  let workspace = null;
12
17
 
13
18
  return async function wrappedNextJSFunction(req, res) {
19
+ if (!checkRequest(req, res)) {
20
+ return;
21
+ }
22
+
23
+ const route = req.query?.studioRoute;
24
+ if (route != null && (!Array.isArray(route) || route.length > 0)) {
25
+ const routePath = Array.isArray(route) ? route.join('/') : null;
26
+ if (routePath !== 'mcp' && routePath !== '.well-known/oauth-protected-resource') {
27
+ return res.status(404).json({ message: 'Not found' });
28
+ }
29
+ if (!mcpHandlers) {
30
+ return res.status(404).json({ message: 'Not found' });
31
+ }
32
+ return routePath === 'mcp'
33
+ ? mcpHandlers.endpoint(req, res)
34
+ : mcpHandlers.metadata(req, res);
35
+ }
36
+
14
37
  const params = { ...req.query, ...req.body, ...req.params, authorization: req.headers.authorization };
38
+ delete params.studioRoute;
15
39
  const actionName = params?.action;
16
40
 
17
41
  const authorization = params?.authorization;
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const isBindIPConnection = require('../helpers/isBindIPConnection');
4
+ const isLocalhostConnection = require('../helpers/isLocalhostConnection');
5
+ const normalizeBindIPOption = require('../helpers/normalizeBindIPOption');
6
+
7
+ module.exports = function checkVercelBindIp(options) {
8
+ const hasBindIpOption = options.bindIp != null;
9
+ const bindIp = normalizeBindIPOption(options.bindIp);
10
+
11
+ return function checkRequest(req, res) {
12
+ if (options.apiKey || bindIp === null) {
13
+ return true;
14
+ }
15
+ const ip = (process.env.VERCEL && req.headers['x-vercel-forwarded-for']) ||
16
+ req.ip || req.socket?.remoteAddress || '';
17
+ const allowed = hasBindIpOption
18
+ ? isBindIPConnection({ ip }, bindIp)
19
+ : isLocalhostConnection({ ip });
20
+ if (!allowed) {
21
+ res.status(403).json({ message: 'Mongoose Studio without an API key only accepts localhost or configured bindIp connections' });
22
+ }
23
+ return allowed;
24
+ };
25
+ };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../next');
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const { defaultMothershipURL } = require('../../constants');
4
+
5
+ module.exports = function vercelMCPConfig(options) {
6
+ if (!options.publicUrl) {
7
+ throw new Error('Mongoose Studio requires publicUrl when Vercel MCP is enabled with an API key');
8
+ }
9
+ const baseUrl = new URL(options.publicUrl);
10
+ baseUrl.search = '';
11
+ baseUrl.hash = '';
12
+ baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, '')}/`;
13
+ const resource = new URL(options._mcpPath || 'mcp', baseUrl).toString();
14
+ const metadataUrl = new URL('.well-known/oauth-protected-resource', baseUrl).toString();
15
+ const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
16
+
17
+ return {
18
+ resource,
19
+ metadataUrl,
20
+ metadata: {
21
+ resource,
22
+ authorization_servers: [new URL(mothershipUrl).origin],
23
+ scopes_supported: ['mcp'],
24
+ bearer_methods_supported: ['header'],
25
+ resource_documentation: 'https://mongoosestudio.app/docs/'
26
+ }
27
+ };
28
+ };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const Backend = require('../');
4
+ const mcp = require('../mcp');
5
+ const mothership = require('../integrations/mothership');
6
+ const vercelMCPConfig = require('./mcpConfig');
7
+ const { defaultMothershipURL } = require('../../constants');
8
+
9
+ module.exports = function createVercelMCPHandlers(conn, options, existingBackend) {
10
+ if (!options.mcp) {
11
+ throw new Error('Mongoose Studio requires mcp: true for the Vercel MCP handler');
12
+ }
13
+ const config = options.apiKey ? vercelMCPConfig(options) : null;
14
+ const { resource, metadataUrl } = config || {};
15
+ const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
16
+ const backend = existingBackend || Backend(conn, options.studioConnection, options);
17
+ let workspacePromise;
18
+ let registered = false;
19
+
20
+ async function workspace() {
21
+ if (!workspacePromise) {
22
+ workspacePromise = fetch(`${mothershipUrl}/getWorkspace`, {
23
+ method: 'POST',
24
+ body: JSON.stringify({ apiKey: options.apiKey }),
25
+ headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': 'application/json' }
26
+ }).then(async response => {
27
+ const data = await response.json();
28
+ if (!response.ok) {
29
+ throw new Error(data.message || `Mongoose Studio API Key Error ${response.status}`);
30
+ }
31
+ options.workspace = data.workspace;
32
+ return data.workspace;
33
+ }).catch(err => {
34
+ workspacePromise = null;
35
+ throw err;
36
+ });
37
+ }
38
+ return workspacePromise;
39
+ }
40
+
41
+ async function registerResource() {
42
+ if (registered) {
43
+ return;
44
+ }
45
+ try {
46
+ await mothership.Workspace.registerMCPResource({ apiKey: options.apiKey, resource }, options);
47
+ registered = true;
48
+ } catch (err) {
49
+ console.warn(`[MONGOOSE STUDIO] Could not register MCP resource ${resource}: ${err.message}`);
50
+ }
51
+ }
52
+
53
+ function challenge(res, error) {
54
+ const detail = error ? `, error="invalid_token", error_description="${error.replace(/"/g, '')}"` : '';
55
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${metadataUrl}"${detail}`);
56
+ }
57
+
58
+ function cors(req, res) {
59
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
60
+ res.setHeader('Vary', 'Origin');
61
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
62
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Protocol-Version, Mcp-Session-Id, Last-Event-ID');
63
+ res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version');
64
+ res.setHeader('Access-Control-Max-Age', '86400');
65
+ if (req.method === 'OPTIONS') {
66
+ res.status(204).end();
67
+ return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ async function metadata(req, res) {
73
+ if (!options.apiKey) {
74
+ return res.status(404).json({ message: 'Not found' });
75
+ }
76
+ if (cors(req, res)) {
77
+ return;
78
+ }
79
+ if (req.method !== 'GET') {
80
+ return res.status(405).end();
81
+ }
82
+ try {
83
+ await workspace();
84
+ await registerResource();
85
+ res.setHeader('Cache-Control', 'no-store');
86
+ return res.status(200).json(config.metadata);
87
+ } catch (err) {
88
+ return res.status(500).json({ message: err.message });
89
+ }
90
+ }
91
+
92
+ async function endpoint(req, res) {
93
+ if (cors(req, res)) {
94
+ return;
95
+ }
96
+ if (!options.apiKey) {
97
+ return mcp(backend)(req, res);
98
+ }
99
+ try {
100
+ const currentWorkspace = await workspace();
101
+ const token = `${req.headers.authorization || ''}`.replace(/^Bearer\s+/i, '').trim();
102
+ if (!token) {
103
+ await registerResource();
104
+ challenge(res);
105
+ return res.status(401).json({ message: 'Not authorized' });
106
+ }
107
+
108
+ let user;
109
+ let roles;
110
+ const context = { $workspaceId: currentWorkspace._id };
111
+ if (token.startsWith('mcp_at_')) {
112
+ const result = await mothership.MCPOAuthToken.introspectMCPAccessToken({
113
+ apiKey: options.apiKey, token, resource
114
+ }, options);
115
+ user = result.user;
116
+ roles = result.permissions.roles;
117
+ context.maxTimeMS = result.permissions.maxTimeMS;
118
+ context.readPreference = result.permissions.readPreference;
119
+ } else {
120
+ const response = await fetch(`${mothershipUrl}/me`, {
121
+ method: 'POST',
122
+ body: JSON.stringify({ workspaceId: currentWorkspace._id }),
123
+ headers: { Authorization: token, 'Content-Type': 'application/json' }
124
+ });
125
+ if (!response.ok) {
126
+ return res.status(response.status).json({ message: 'Not authorized' });
127
+ }
128
+ ({ user, roles } = await response.json());
129
+ }
130
+ if (!user || !roles) {
131
+ return res.status(403).json({ message: 'Not authorized' });
132
+ }
133
+ req._internals = {
134
+ ...context,
135
+ authorization: token,
136
+ initiatedById: user._id,
137
+ initiatedBy: user,
138
+ userId: user._id,
139
+ roles
140
+ };
141
+ return mcp(backend)(req, res);
142
+ } catch (err) {
143
+ if (err.status === 401 || err.status === 403) {
144
+ challenge(res, err.message);
145
+ return res.status(401).json({ message: err.message });
146
+ }
147
+ return res.status(500).json({ message: err.message });
148
+ }
149
+ }
150
+
151
+ return { endpoint, metadata };
152
+ };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const Backend = require('../');
4
+ const checkVercelBindIp = require('./checkBindIp');
5
+ const vercelMCP = require('./vercelMCP');
6
+
7
+ module.exports = function vercelMCPRouteHandler(conn, options) {
8
+ options = { ...options, mcp: true, _mcpPath: '/api/studio-mcp' };
9
+ const checkRequest = checkVercelBindIp(options);
10
+ const backend = Backend(conn, options.studioConnection, options);
11
+ const { endpoint } = vercelMCP(conn, options, backend);
12
+
13
+ return function wrappedVercelMCPRouteHandler(req, res) {
14
+ if (!checkRequest(req, res)) {
15
+ return;
16
+ }
17
+ return endpoint(req, res);
18
+ };
19
+ };
package/docs/mcp-oauth.md CHANGED
@@ -24,7 +24,7 @@ It publishes metadata that tells clients where the authorization server is, and
24
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
25
 
26
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.
27
+ Access tokens live 24 hours.
28
28
  Refresh tokens live 30 days and rotate on every use, so a user does not have to reconnect the client regularly.
29
29
 
30
30
  ## Authorization grants
@@ -84,17 +84,18 @@ They can only narrow what the host application already allows: `maxTimeMS` is ca
84
84
 
85
85
  ## Configuration
86
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:
87
+ MCP is disabled by default when Mongoose Studio has a Pro API key.
88
+ To enable it, set `mcp: true` and set `publicUrl` to the externally reachable URL where Mongoose Studio is mounted:
89
89
 
90
90
  ```javascript
91
91
  app.use('/studio', await studio('/studio/api', mongoose, {
92
92
  apiKey: process.env.MONGOOSE_STUDIO_API_KEY,
93
+ mcp: true,
93
94
  publicUrl: 'https://app.example.com/studio'
94
95
  }));
95
96
  ```
96
97
 
97
- Pass `mcp: false` to serve Studio without an MCP endpoint at all.
98
+ Pass `mcp: false` to serve Mongoose Studio without an MCP endpoint at all.
98
99
  Neither the endpoint nor its protected resource metadata is then mounted, so there is nothing for an MCP client to discover.
99
100
  Without an API key, the MCP endpoint follows the normal localhost or `bindIp` restrictions and does not mount OAuth protected resource metadata.
100
101
 
@@ -102,5 +103,5 @@ Mongoose Studio registers that URL with the authorization server the first time
102
103
  It advertises the configured mothership's origin in `authorization_servers`, keeping authentication with the same service Mongoose Studio already uses.
103
104
 
104
105
  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
+ Mongoose Studio therefore serves the MCP endpoint and its metadata with CORS, and exposes `WWW-Authenticate` via `Access-Control-Expose-Headers`.
106
107
  Without that last header a browser hides the challenge from the client, and the client cannot find the authorization server at all.
package/express.js CHANGED
@@ -17,10 +17,11 @@ const MCP_ACCESS_TOKEN_PREFIX = 'mcp_at_';
17
17
  module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options) {
18
18
  const router = express.Router();
19
19
  options = options ? { changeStream: true, ...options } : { changeStream: true };
20
- if (options.mcp !== false && options.apiKey && !options.publicUrl) {
20
+ options.mcp = options.mcp ?? !options.apiKey;
21
+ if (options.mcp && options.apiKey && !options.publicUrl) {
21
22
  throw new Error('Mongoose Studio requires the publicUrl option when MCP is enabled with an API key');
22
23
  }
23
- const hasBindIpOption = Object.prototype.hasOwnProperty.call(options, 'bindIp');
24
+ const hasBindIpOption = options.bindIp != null;
24
25
  const bindIp = normalizeBindIPOption(options.bindIp);
25
26
 
26
27
  const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
@@ -124,10 +125,10 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
124
125
  // issued by the mothership. Mongoose Studio is the protected resource: it publishes the
125
126
  // metadata that points clients at the authorization server, and resolves each
126
127
  // token to its current grant before handling the request.
127
- const mcpEnabled = options.mcp !== false;
128
+ const mcpEnabled = options.mcp;
128
129
  const registeredMCPResources = new Set();
129
130
  const mcpAuthorizationServerUrl = new URL(mothershipUrl).origin;
130
- const mcpResource = workspace ? canonicalize(`${options.publicUrl.replace(/\/+$/, '')}/mcp`) : null;
131
+ const mcpResource = mcpEnabled && workspace ? canonicalize(`${options.publicUrl.replace(/\/+$/, '')}/mcp`) : null;
131
132
 
132
133
  async function registerMCPResource(resource) {
133
134
  if (registeredMCPResources.has(resource)) {
package/frontend/index.js CHANGED
@@ -5,6 +5,8 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const webpack = require('webpack');
7
7
  const { defaultMothershipURL } = require('../constants');
8
+ const mothership = require('../backend/integrations/mothership');
9
+ const vercelMCPConfig = require('../backend/vercel/mcpConfig');
8
10
  const webpackConfig = require('./webpack.config');
9
11
  const util = require('util');
10
12
 
@@ -33,11 +35,18 @@ module.exports = async function frontend(apiUrl, isLambda, options, workspace) {
33
35
 
34
36
  const { apiKey, ...workspaceData } = workspace || {};
35
37
  const workspaceBaseUrl = workspace?.baseUrl?.replace(/\/+$/, '');
38
+ const publicBaseUrl = options?.publicUrl?.replace(/\/+$/, '') || workspaceBaseUrl;
39
+ const mcpPath = options?._mcpPath || (isLambda && options?.mcp === true ? '/api/studio-mcp' : null);
40
+ const mcpEnabled = options?.mcp ?? !options?.apiKey;
36
41
  const config = {
37
42
  baseURL: apiUrl,
38
43
  isLambda,
39
- mcp: options?.mcp !== false,
40
- mcpUrl: workspaceBaseUrl ? `${workspaceBaseUrl}/mcp` : null,
44
+ mcp: mcpEnabled,
45
+ mcpUrl: mcpEnabled
46
+ ? mcpPath
47
+ ? publicBaseUrl ? new URL(mcpPath, `${publicBaseUrl}/`).toString() : mcpPath
48
+ : publicBaseUrl ? `${publicBaseUrl}/mcp` : null
49
+ : null,
41
50
  mothershipUrl: apiKey ? mothershipUrl : null,
42
51
  workspace: workspaceData
43
52
  };
@@ -45,6 +54,19 @@ module.exports = async function frontend(apiUrl, isLambda, options, workspace) {
45
54
  if (isLambda) {
46
55
  const configPath = path.join(__dirname, './public/config.js');
47
56
  fs.writeFileSync(configPath, `window.MONGOOSE_STUDIO_CONFIG = ${JSON.stringify(config, null, 2)};`);
57
+
58
+ // The caller copies frontend/public after this promise resolves. Put the
59
+ // protected resource document there so the same copy includes it.
60
+ const metadataPath = path.join(__dirname, 'public/.well-known/oauth-protected-resource');
61
+ if (options?.apiKey && options?.mcp === true) {
62
+ fs.rmSync(metadataPath, { force: true });
63
+ const mcpConfig = vercelMCPConfig({ ...options, _mcpPath: '/api/studio-mcp' });
64
+ await mothership.Workspace.registerMCPResource({ apiKey: options.apiKey, resource: mcpConfig.resource }, options);
65
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
66
+ fs.writeFileSync(metadataPath, JSON.stringify(mcpConfig.metadata, null, 2));
67
+ } else {
68
+ fs.rmSync(metadataPath, { force: true });
69
+ }
48
70
  }
49
71
 
50
72
  if (options && options.__watch) {