@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 +96 -0
- package/README.md +91 -9
- package/backend/next.js +24 -0
- package/backend/vercel/checkBindIp.js +25 -0
- package/backend/vercel/index.js +3 -0
- package/backend/vercel/mcpConfig.js +28 -0
- package/backend/vercel/vercelMCP.js +152 -0
- package/backend/vercel/vercelMCPRouteHandler.js +19 -0
- package/docs/mcp-oauth.md +6 -5
- package/express.js +5 -4
- package/frontend/index.js +24 -2
- package/frontend/public/app.js +263 -111
- package/frontend/src/mcp/mcp.js +5 -2
- package/index.d.ts +1 -1
- package/nest.d.ts +1 -0
- package/next.js +35 -8
- package/package.json +1 -1
- package/vercel.d.ts +14 -1
- package/vercel.js +1 -0
- package/backend/vercel.js +0 -3
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
|
-
|
|
60
|
-
Set `mcp: false` to turn it off
|
|
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`
|
|
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
|
|
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
|
-
|
|
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 '
|
|
101
|
-
import
|
|
109
|
+
import db from '../../../src/db';
|
|
110
|
+
import { vercelAPIRouteHandler } from '@mongoosejs/studio/vercel';
|
|
102
111
|
|
|
103
|
-
const handler =
|
|
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,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
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
40
|
-
mcpUrl:
|
|
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) {
|