@mongoosejs/studio 0.6.1 → 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 +58 -0
- package/README.md +85 -4
- 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 +1 -1
- package/express.js +1 -1
- package/frontend/index.js +24 -2
- package/frontend/public/app.js +263 -111
- package/frontend/src/mcp/mcp.js +5 -2
- 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
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
61
|
## 0.6.1
|
|
4
62
|
|
|
5
63
|
### Safer MCP defaults with API keys
|
package/README.md
CHANGED
|
@@ -91,28 +91,109 @@ import withMongooseStudio from '@mongoosejs/studio/next';
|
|
|
91
91
|
export default withMongooseStudio({
|
|
92
92
|
// Your Next.js config here
|
|
93
93
|
reactStrictMode: true,
|
|
94
|
+
}, {
|
|
95
|
+
mcp: true,
|
|
96
|
+
publicUrl: process.env.MONGOOSE_STUDIO_PUBLIC_URL
|
|
94
97
|
});
|
|
95
98
|
```
|
|
96
99
|
|
|
97
|
-
|
|
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.
|
|
98
105
|
|
|
99
106
|
```javascript
|
|
107
|
+
// pages/api/studio/[[...studioRoute]].js
|
|
100
108
|
// Make sure to import the database connection
|
|
101
|
-
import db from '
|
|
102
|
-
import
|
|
109
|
+
import db from '../../../src/db';
|
|
110
|
+
import { vercelAPIRouteHandler } from '@mongoosejs/studio/vercel';
|
|
103
111
|
|
|
104
|
-
const handler =
|
|
112
|
+
const handler = vercelAPIRouteHandler(
|
|
105
113
|
db, // Mongoose connection or Mongoose global. Or null to use `import mongoose`.
|
|
106
114
|
{
|
|
107
115
|
apiKey: process.env.MONGOOSE_STUDIO_API_KEY, // optional
|
|
108
116
|
connection: db, // Optional: Connection or Mongoose global. If omitted, will use `import mongoose`
|
|
109
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
|
|
110
120
|
}
|
|
111
121
|
);
|
|
112
122
|
|
|
113
123
|
export default handler;
|
|
114
124
|
```
|
|
115
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
|
+
|
|
116
197
|
### Nest.js
|
|
117
198
|
|
|
118
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
|
package/express.js
CHANGED
|
@@ -21,7 +21,7 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
|
|
|
21
21
|
if (options.mcp && options.apiKey && !options.publicUrl) {
|
|
22
22
|
throw new Error('Mongoose Studio requires the publicUrl option when MCP is enabled with an API key');
|
|
23
23
|
}
|
|
24
|
-
const hasBindIpOption =
|
|
24
|
+
const hasBindIpOption = options.bindIp != null;
|
|
25
25
|
const bindIp = normalizeBindIPOption(options.bindIp);
|
|
26
26
|
|
|
27
27
|
const mothershipUrl = options._mothershipUrl || defaultMothershipURL;
|
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) {
|