backend-manager 5.6.3 → 5.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 +43 -0
- package/CLAUDE.md +4 -3
- package/PROGRESS.md +34 -0
- package/docs/ai-library.md +62 -11
- package/docs/cdp-debugging.md +44 -0
- package/docs/cli-output.md +22 -10
- package/docs/mcp.md +166 -43
- package/docs/test-framework.md +2 -2
- package/package.json +1 -1
- package/plans/mcp2.md +247 -0
- package/src/cli/commands/mcp.js +8 -2
- package/src/cli/commands/serve.js +155 -29
- package/src/cli/commands/setup-tests/base-test.js +8 -0
- package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
- package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
- package/src/cli/commands/setup-tests/index.js +4 -0
- package/src/cli/commands/setup-tests/java-installed.js +26 -0
- package/src/cli/commands/setup.js +2 -1
- package/src/cli/commands/test.js +13 -0
- package/src/cli/index.js +14 -0
- package/src/cli/utils/ui.js +27 -5
- package/src/manager/index.js +8 -3
- package/src/manager/libraries/ai/index.js +45 -1
- package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
- package/src/manager/libraries/ai/providers/anthropic.js +28 -49
- package/src/manager/libraries/ai/providers/claude-code.js +21 -47
- package/src/manager/libraries/ai/providers/openai.js +154 -19
- package/src/manager/libraries/ai/providers/test.js +242 -0
- package/src/manager/libraries/email/data/disposable-domains.json +465 -0
- package/src/mcp/client.js +48 -13
- package/src/mcp/handler.js +222 -69
- package/src/mcp/index.js +48 -18
- package/src/mcp/tools.js +150 -0
- package/src/mcp/utils.js +108 -0
- package/src/test/fixtures/firebase-project/firebase.json +1 -1
- package/src/test/test-accounts.js +31 -0
- package/test/ai/tools-live.js +170 -0
- package/test/email/marketing-lifecycle.js +10 -5
- package/test/helpers/ai-test-provider.js +202 -0
- package/test/helpers/ai-tools-format.js +350 -0
- package/test/mcp/discovery.js +53 -0
- package/test/mcp/oauth.js +161 -0
- package/test/mcp/protocol.js +268 -0
- package/test/mcp/roles.js +168 -0
- package/test/mcp/utils.js +245 -0
- package/test/routes/marketing/webhook.js +37 -33
- package/.claude/settings.local.json +0 -12
package/src/mcp/client.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* BEM HTTP Client
|
|
3
3
|
*
|
|
4
4
|
* Makes authenticated HTTP calls to a running BEM server (local or production).
|
|
5
|
+
* Supports admin key auth (backendManagerKey) and user token auth (API key from OAuth flow).
|
|
5
6
|
*/
|
|
6
7
|
const fetch = require('wonderful-fetch');
|
|
7
8
|
|
|
@@ -11,6 +12,7 @@ class BEMClient {
|
|
|
11
12
|
|
|
12
13
|
this.baseUrl = (options.baseUrl || '').replace(/\/+$/, '');
|
|
13
14
|
this.backendManagerKey = options.backendManagerKey || '';
|
|
15
|
+
this.userToken = options.userToken || '';
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -35,24 +37,57 @@ class BEMClient {
|
|
|
35
37
|
timeout: 120000,
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
if (
|
|
39
|
-
//
|
|
40
|
-
|
|
40
|
+
if (this.backendManagerKey) {
|
|
41
|
+
// Admin key auth — key in query/body (existing behavior)
|
|
42
|
+
if (method === 'GET') {
|
|
43
|
+
url.searchParams.set('backendManagerKey', this.backendManagerKey);
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
for (const [key, value] of Object.entries(params)) {
|
|
46
|
+
if (value === undefined || value === null) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
|
|
45
51
|
}
|
|
52
|
+
} else {
|
|
53
|
+
fetchOptions.body = JSON.stringify({
|
|
54
|
+
backendManagerKey: this.backendManagerKey,
|
|
55
|
+
...params,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} else if (this.userToken) {
|
|
59
|
+
// User token auth — Bearer header + authenticationToken param
|
|
60
|
+
fetchOptions.headers['Authorization'] = `Bearer ${this.userToken}`;
|
|
61
|
+
|
|
62
|
+
if (method === 'GET') {
|
|
63
|
+
url.searchParams.set('authenticationToken', this.userToken);
|
|
64
|
+
|
|
65
|
+
for (const [key, value] of Object.entries(params)) {
|
|
66
|
+
if (value === undefined || value === null) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
fetchOptions.body = JSON.stringify({
|
|
74
|
+
authenticationToken: this.userToken,
|
|
75
|
+
...params,
|
|
76
|
+
});
|
|
49
77
|
}
|
|
50
78
|
} else {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
// Unauthenticated
|
|
80
|
+
if (method === 'GET') {
|
|
81
|
+
for (const [key, value] of Object.entries(params)) {
|
|
82
|
+
if (value === undefined || value === null) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
fetchOptions.body = JSON.stringify(params);
|
|
90
|
+
}
|
|
56
91
|
}
|
|
57
92
|
|
|
58
93
|
const response = await fetch(url.toString(), fetchOptions);
|
package/src/mcp/handler.js
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP HTTP Handler (Stateless + OAuth)
|
|
2
|
+
* MCP HTTP Handler (Stateless + OAuth + Role-Based Scoping)
|
|
3
3
|
*
|
|
4
4
|
* Routes all MCP-related requests:
|
|
5
5
|
* - OAuth discovery (.well-known endpoints)
|
|
6
|
-
* - OAuth authorize + token (
|
|
7
|
-
* - MCP protocol (stateless Streamable HTTP transport)
|
|
6
|
+
* - OAuth authorize + token (admin key auto-approve OR user sign-in via consumer website)
|
|
7
|
+
* - MCP protocol (stateless Streamable HTTP transport, role-filtered tools)
|
|
8
8
|
*
|
|
9
9
|
* Compatible with serverless environments like Firebase Functions.
|
|
10
|
-
* No tokens stored — the backendManagerKey IS the access token.
|
|
11
10
|
*/
|
|
12
11
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
13
12
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
14
13
|
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
15
|
-
const
|
|
14
|
+
const builtinTools = require('./tools.js');
|
|
16
15
|
const BEMClient = require('./client.js');
|
|
16
|
+
const { resolveAuthInfo, filterToolsByRole, loadConsumerTools, buildToolMap } = require('./utils.js');
|
|
17
17
|
const packageJSON = require('../../package.json');
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// Consumer tools are cached at module scope (loaded once per cold start)
|
|
20
|
+
let _consumerToolsCache = null;
|
|
21
|
+
let _consumerToolsCwd = null;
|
|
22
|
+
|
|
23
|
+
function getConsumerTools(cwd) {
|
|
24
|
+
if (_consumerToolsCwd === cwd && _consumerToolsCache !== null) {
|
|
25
|
+
return _consumerToolsCache;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_consumerToolsCache = loadConsumerTools(cwd);
|
|
29
|
+
_consumerToolsCwd = cwd;
|
|
30
|
+
|
|
31
|
+
return _consumerToolsCache;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -33,27 +42,25 @@ for (const tool of tools) {
|
|
|
33
42
|
*/
|
|
34
43
|
async function handleMcpRoute(req, res, options) {
|
|
35
44
|
const { Manager, routePath } = options;
|
|
36
|
-
// Build base URL from the incoming request so discovery URLs match however the client reached us
|
|
37
|
-
// (ngrok, production domain, localhost, etc.)
|
|
38
45
|
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
|
|
39
46
|
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
40
47
|
const baseUrl = `${protocol}://${host}`;
|
|
41
48
|
|
|
42
49
|
// --- OAuth Discovery ---
|
|
50
|
+
// issuer = root (no path) so RFC 8414 discovery resolves to /.well-known/oauth-authorization-server
|
|
43
51
|
if (routePath === '.well-known/oauth-protected-resource') {
|
|
44
52
|
return sendJson(res, 200, {
|
|
45
53
|
resource: `${baseUrl}/backend-manager/mcp`,
|
|
46
|
-
authorization_servers: [
|
|
47
|
-
`${baseUrl}/backend-manager/mcp`,
|
|
48
|
-
],
|
|
54
|
+
authorization_servers: [baseUrl],
|
|
49
55
|
});
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
if (routePath === '.well-known/oauth-authorization-server') {
|
|
53
59
|
return sendJson(res, 200, {
|
|
54
|
-
issuer:
|
|
60
|
+
issuer: baseUrl,
|
|
55
61
|
authorization_endpoint: `${baseUrl}/backend-manager/mcp/authorize`,
|
|
56
62
|
token_endpoint: `${baseUrl}/backend-manager/mcp/token`,
|
|
63
|
+
registration_endpoint: `${baseUrl}/backend-manager/mcp/register`,
|
|
57
64
|
response_types_supported: ['code'],
|
|
58
65
|
grant_types_supported: ['authorization_code'],
|
|
59
66
|
code_challenge_methods_supported: ['S256'],
|
|
@@ -61,9 +68,14 @@ async function handleMcpRoute(req, res, options) {
|
|
|
61
68
|
});
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
// --- OAuth Dynamic Client Registration (RFC 7591) ---
|
|
72
|
+
if (routePath === 'mcp/register') {
|
|
73
|
+
return handleRegister(req, res);
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
// --- OAuth Authorize ---
|
|
65
77
|
if (routePath === 'mcp/authorize') {
|
|
66
|
-
return handleAuthorize(req, res, options);
|
|
78
|
+
return handleAuthorize(req, res, options, baseUrl);
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
// --- OAuth Token ---
|
|
@@ -82,30 +94,38 @@ async function handleMcpRoute(req, res, options) {
|
|
|
82
94
|
/**
|
|
83
95
|
* OAuth Authorize
|
|
84
96
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
97
|
+
* Three paths:
|
|
98
|
+
* 1. client_id matches admin key → auto-redirect (no form, no sign-in)
|
|
99
|
+
* 2. No matching key → redirect to consumer's website for user sign-in
|
|
100
|
+
* 3. Fallback → show manual key entry form
|
|
89
101
|
*/
|
|
90
|
-
function handleAuthorize(req, res, options) {
|
|
102
|
+
function handleAuthorize(req, res, options, baseUrl) {
|
|
91
103
|
const query = req.query || {};
|
|
92
104
|
const { redirect_uri, state, client_id } = query;
|
|
93
105
|
const Manager = options.Manager;
|
|
94
106
|
|
|
95
|
-
// Auto-approve if client_id matches the
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
// Auto-approve if client_id matches the admin key
|
|
108
|
+
if (isAdminKey(client_id) && redirect_uri) {
|
|
109
|
+
return redirectWithCode(res, redirect_uri, client_id, state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Try to redirect to consumer's website for user sign-in
|
|
113
|
+
const consumerAuthUrl = resolveConsumerAuthUrl(Manager);
|
|
114
|
+
|
|
115
|
+
if (consumerAuthUrl && redirect_uri) {
|
|
116
|
+
const authUrl = new URL(consumerAuthUrl);
|
|
117
|
+
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
|
99
118
|
if (state) {
|
|
100
|
-
|
|
119
|
+
authUrl.searchParams.set('state', state);
|
|
101
120
|
}
|
|
102
|
-
|
|
121
|
+
authUrl.searchParams.set('mcp', 'true');
|
|
122
|
+
res.writeHead(302, { Location: authUrl.toString() });
|
|
103
123
|
res.end();
|
|
104
124
|
return;
|
|
105
125
|
}
|
|
106
126
|
|
|
127
|
+
// Fallback: show manual key entry form
|
|
107
128
|
if (req.method === 'GET') {
|
|
108
|
-
// Show a simple authorize form (fallback when client_id is not the BEM key)
|
|
109
129
|
const html = `<!DOCTYPE html>
|
|
110
130
|
<html>
|
|
111
131
|
<head>
|
|
@@ -151,7 +171,7 @@ function handleAuthorize(req, res, options) {
|
|
|
151
171
|
const redirectUri = body.redirect_uri || '';
|
|
152
172
|
const postState = body.state || '';
|
|
153
173
|
|
|
154
|
-
if (!
|
|
174
|
+
if (!isAdminKey(key)) {
|
|
155
175
|
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
156
176
|
res.end('<html><body style="background:#111;color:#e55;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh"><h2>Invalid key. Go back and try again.</h2></body></html>');
|
|
157
177
|
return;
|
|
@@ -161,24 +181,20 @@ function handleAuthorize(req, res, options) {
|
|
|
161
181
|
return sendJson(res, 400, { error: 'Missing redirect_uri' });
|
|
162
182
|
}
|
|
163
183
|
|
|
164
|
-
|
|
165
|
-
url.searchParams.set('code', key);
|
|
166
|
-
if (postState) {
|
|
167
|
-
url.searchParams.set('state', postState);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
res.writeHead(302, { Location: url.toString() });
|
|
171
|
-
res.end();
|
|
172
|
-
return;
|
|
184
|
+
return redirectWithCode(res, redirectUri, key, postState);
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
sendJson(res, 405, { error: 'Method not allowed' });
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
/**
|
|
179
|
-
* OAuth Token — exchanges
|
|
191
|
+
* OAuth Token — exchanges an auth code for an access token.
|
|
192
|
+
*
|
|
193
|
+
* Two paths:
|
|
194
|
+
* 1. Code is the admin key → return it as access_token (existing behavior)
|
|
195
|
+
* 2. Code is a Firebase ID token → verify, look up user, return api.privateKey
|
|
180
196
|
*/
|
|
181
|
-
function handleToken(req, res, options) {
|
|
197
|
+
async function handleToken(req, res, options) {
|
|
182
198
|
if (req.method !== 'POST') {
|
|
183
199
|
return sendJson(res, 405, { error: 'Method not allowed' });
|
|
184
200
|
}
|
|
@@ -187,45 +203,131 @@ function handleToken(req, res, options) {
|
|
|
187
203
|
const code = body.code || body.client_secret || body.client_id || '';
|
|
188
204
|
const Manager = options.Manager;
|
|
189
205
|
|
|
190
|
-
//
|
|
191
|
-
if (
|
|
192
|
-
return sendJson(res,
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
// Path 1: admin key
|
|
207
|
+
if (isAdminKey(code)) {
|
|
208
|
+
return sendJson(res, 200, {
|
|
209
|
+
access_token: code,
|
|
210
|
+
token_type: 'Bearer',
|
|
211
|
+
scope: 'tools',
|
|
195
212
|
});
|
|
196
213
|
}
|
|
197
214
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
// Path 2: Firebase ID token → exchange for user's API key
|
|
216
|
+
if (code) {
|
|
217
|
+
try {
|
|
218
|
+
const admin = Manager.libraries?.admin;
|
|
219
|
+
|
|
220
|
+
if (!admin) {
|
|
221
|
+
return sendJson(res, 500, {
|
|
222
|
+
error: 'server_error',
|
|
223
|
+
error_description: 'Firebase Admin not available.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const decoded = await admin.auth().verifyIdToken(code);
|
|
228
|
+
const uid = decoded.uid;
|
|
229
|
+
|
|
230
|
+
const userDoc = await admin.firestore().doc(`users/${uid}`).get();
|
|
231
|
+
|
|
232
|
+
if (!userDoc.exists) {
|
|
233
|
+
return sendJson(res, 401, {
|
|
234
|
+
error: 'invalid_grant',
|
|
235
|
+
error_description: 'User not found.',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const userData = userDoc.data();
|
|
240
|
+
let apiKey = userData?.api?.privateKey;
|
|
241
|
+
|
|
242
|
+
// Generate an API key if the user doesn't have one
|
|
243
|
+
if (!apiKey) {
|
|
244
|
+
const { v4: uuidv4 } = require('uuid');
|
|
245
|
+
apiKey = `pk_${uuidv4().replace(/-/g, '')}`;
|
|
246
|
+
|
|
247
|
+
await admin.firestore().doc(`users/${uid}`).set(
|
|
248
|
+
{ api: { privateKey: apiKey } },
|
|
249
|
+
{ merge: true },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return sendJson(res, 200, {
|
|
254
|
+
access_token: apiKey,
|
|
255
|
+
token_type: 'Bearer',
|
|
256
|
+
scope: 'tools',
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return sendJson(res, 401, {
|
|
260
|
+
error: 'invalid_grant',
|
|
261
|
+
error_description: error.message || 'Invalid authorization code.',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
sendJson(res, 401, {
|
|
267
|
+
error: 'invalid_grant',
|
|
268
|
+
error_description: 'Missing authorization code.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* OAuth Dynamic Client Registration (RFC 7591)
|
|
274
|
+
* MCP clients register themselves before starting the auth flow.
|
|
275
|
+
* We accept any client and return a generated client_id.
|
|
276
|
+
*/
|
|
277
|
+
function handleRegister(req, res) {
|
|
278
|
+
if (req.method !== 'POST') {
|
|
279
|
+
return sendJson(res, 405, { error: 'Method not allowed' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const body = req.body || {};
|
|
283
|
+
const { v4: uuidv4 } = require('uuid');
|
|
284
|
+
const clientId = `mcp_${uuidv4().replace(/-/g, '')}`;
|
|
285
|
+
|
|
286
|
+
sendJson(res, 201, {
|
|
287
|
+
client_id: clientId,
|
|
288
|
+
client_name: body.client_name || 'MCP Client',
|
|
289
|
+
redirect_uris: body.redirect_uris || [],
|
|
290
|
+
grant_types: ['authorization_code'],
|
|
291
|
+
response_types: ['code'],
|
|
292
|
+
token_endpoint_auth_method: 'none',
|
|
203
293
|
});
|
|
204
294
|
}
|
|
205
295
|
|
|
206
296
|
/**
|
|
207
|
-
* MCP Protocol — stateless Streamable HTTP transport
|
|
297
|
+
* MCP Protocol — stateless Streamable HTTP transport with role-based tool filtering
|
|
208
298
|
*/
|
|
209
299
|
async function handleMcpProtocol(req, res, options) {
|
|
210
300
|
const { Manager } = options;
|
|
211
301
|
|
|
212
|
-
//
|
|
302
|
+
// Extract Bearer token
|
|
213
303
|
const authHeader = req.headers.authorization || '';
|
|
214
|
-
const
|
|
304
|
+
const token = authHeader.replace(/^Bearer\s+/i, '');
|
|
215
305
|
|
|
216
|
-
|
|
217
|
-
|
|
306
|
+
// No token → 401 to trigger the OAuth flow (MCP spec requires this)
|
|
307
|
+
if (!token) {
|
|
218
308
|
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
|
|
219
309
|
const host = req.headers['x-forwarded-host'] || req.headers.host || '';
|
|
220
310
|
const baseUrl = `${protocol}://${host}`;
|
|
221
311
|
res.writeHead(401, {
|
|
222
312
|
'Content-Type': 'application/json',
|
|
223
|
-
'WWW-Authenticate': `Bearer resource_metadata="${baseUrl}
|
|
313
|
+
'WWW-Authenticate': `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`,
|
|
224
314
|
});
|
|
225
315
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
226
316
|
return;
|
|
227
317
|
}
|
|
228
318
|
|
|
319
|
+
// Classify the token
|
|
320
|
+
const authInfo = resolveAuthInfo(token);
|
|
321
|
+
|
|
322
|
+
// Load and merge consumer tools (consumer overrides win)
|
|
323
|
+
const cwd = Manager.cwd || '';
|
|
324
|
+
const consumerTools = getConsumerTools(cwd);
|
|
325
|
+
const toolMap = buildToolMap(builtinTools, consumerTools);
|
|
326
|
+
const allTools = Array.from(toolMap.values());
|
|
327
|
+
|
|
328
|
+
// Filter by role
|
|
329
|
+
const visibleTools = filterToolsByRole(allTools, authInfo.role);
|
|
330
|
+
|
|
229
331
|
// Only POST supported in stateless mode
|
|
230
332
|
if (req.method !== 'POST') {
|
|
231
333
|
if (req.method === 'DELETE') {
|
|
@@ -239,9 +341,13 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
239
341
|
});
|
|
240
342
|
}
|
|
241
343
|
|
|
242
|
-
//
|
|
243
|
-
const apiUrl = Manager.
|
|
244
|
-
const client = new BEMClient({
|
|
344
|
+
// Build client with appropriate auth
|
|
345
|
+
const apiUrl = Manager.getApiUrl();
|
|
346
|
+
const client = new BEMClient({
|
|
347
|
+
baseUrl: apiUrl,
|
|
348
|
+
backendManagerKey: authInfo.role === 'admin' ? token : '',
|
|
349
|
+
userToken: authInfo.role === 'user' ? token : '',
|
|
350
|
+
});
|
|
245
351
|
|
|
246
352
|
// Create a fresh stateless transport
|
|
247
353
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -261,13 +367,15 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
261
367
|
},
|
|
262
368
|
);
|
|
263
369
|
|
|
264
|
-
// List tools
|
|
370
|
+
// List tools — role-filtered
|
|
265
371
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
266
372
|
return {
|
|
267
|
-
tools:
|
|
373
|
+
tools: visibleTools.map((tool) => ({
|
|
268
374
|
name: tool.name,
|
|
269
375
|
description: tool.description,
|
|
270
376
|
inputSchema: tool.inputSchema,
|
|
377
|
+
outputSchema: tool.outputSchema,
|
|
378
|
+
annotations: tool.annotations,
|
|
271
379
|
})),
|
|
272
380
|
};
|
|
273
381
|
});
|
|
@@ -275,9 +383,10 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
275
383
|
// Call tools
|
|
276
384
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
277
385
|
const { name, arguments: args } = request.params;
|
|
278
|
-
const tool = toolMap
|
|
386
|
+
const tool = toolMap.get(name);
|
|
279
387
|
|
|
280
|
-
|
|
388
|
+
// Defense-in-depth: tool must exist AND be in the visible set
|
|
389
|
+
if (!tool || !visibleTools.some((t) => t.name === name)) {
|
|
281
390
|
return {
|
|
282
391
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
283
392
|
isError: true,
|
|
@@ -285,6 +394,26 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
285
394
|
}
|
|
286
395
|
|
|
287
396
|
try {
|
|
397
|
+
// Handler-based consumer tools execute directly
|
|
398
|
+
if (tool.handler && tool._consumer) {
|
|
399
|
+
const result = await tool.handler({
|
|
400
|
+
Manager,
|
|
401
|
+
assistant: Manager.assistant,
|
|
402
|
+
user: null,
|
|
403
|
+
params: args || {},
|
|
404
|
+
libraries: Manager.libraries,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const text = typeof result === 'string'
|
|
408
|
+
? result
|
|
409
|
+
: JSON.stringify(result, null, 2);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
content: [{ type: 'text', text }],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Route-based tools call via HTTP
|
|
288
417
|
const response = await client.call(tool.method, tool.path, args || {});
|
|
289
418
|
|
|
290
419
|
const text = typeof response === 'string'
|
|
@@ -300,7 +429,7 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
300
429
|
: error.message;
|
|
301
430
|
|
|
302
431
|
return {
|
|
303
|
-
content: [{ type: 'text', text: `Error calling ${
|
|
432
|
+
content: [{ type: 'text', text: `Error calling ${name}: ${message}` }],
|
|
304
433
|
isError: true,
|
|
305
434
|
};
|
|
306
435
|
}
|
|
@@ -317,15 +446,39 @@ async function handleMcpProtocol(req, res, options) {
|
|
|
317
446
|
|
|
318
447
|
// --- Helpers ---
|
|
319
448
|
|
|
320
|
-
|
|
321
|
-
* Validate a key against the configured backendManagerKey.
|
|
322
|
-
* Returns false if either the key or the config key is empty/missing.
|
|
323
|
-
*/
|
|
324
|
-
function isValidKey(key) {
|
|
449
|
+
function isAdminKey(key) {
|
|
325
450
|
const configKey = process.env.BACKEND_MANAGER_KEY || '';
|
|
326
451
|
return !!key && !!configKey && key === configKey;
|
|
327
452
|
}
|
|
328
453
|
|
|
454
|
+
function resolveConsumerAuthUrl(Manager) {
|
|
455
|
+
// Check backend-manager-config.json for explicit mcp.authUrl
|
|
456
|
+
const mcpConfig = Manager.config?.mcp || {};
|
|
457
|
+
|
|
458
|
+
if (mcpConfig.authUrl) {
|
|
459
|
+
return mcpConfig.authUrl;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Use getWebsiteUrl() — auto-resolves to localhost in dev/testing, production otherwise
|
|
463
|
+
const websiteUrl = Manager.getWebsiteUrl ? Manager.getWebsiteUrl() : null;
|
|
464
|
+
|
|
465
|
+
if (websiteUrl) {
|
|
466
|
+
return `${websiteUrl.replace(/\/+$/, '')}/token`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function redirectWithCode(res, redirectUri, code, state) {
|
|
473
|
+
const url = new URL(redirectUri);
|
|
474
|
+
url.searchParams.set('code', code);
|
|
475
|
+
if (state) {
|
|
476
|
+
url.searchParams.set('state', state);
|
|
477
|
+
}
|
|
478
|
+
res.writeHead(302, { Location: url.toString() });
|
|
479
|
+
res.end();
|
|
480
|
+
}
|
|
481
|
+
|
|
329
482
|
function sendJson(res, code, data) {
|
|
330
483
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
331
484
|
res.end(JSON.stringify(data));
|
package/src/mcp/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* BEM MCP Server
|
|
4
|
+
* BEM MCP Server (Stdio Transport)
|
|
5
5
|
*
|
|
6
6
|
* Exposes Backend Manager routes as MCP tools so Claude (or any MCP client)
|
|
7
7
|
* can interact with a running BEM instance — local or production.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
|
-
* npx bm mcp
|
|
10
|
+
* npx bm mcp # admin (uses BACKEND_MANAGER_KEY)
|
|
11
|
+
* npx bm mcp --token <api-key> # user-level (uses API key)
|
|
12
|
+
* npx bm mcp # public-only (no key, no token)
|
|
11
13
|
*
|
|
12
14
|
* Environment variables:
|
|
13
15
|
* BEM_URL - BEM server URL (default: http://localhost:5002)
|
|
@@ -17,7 +19,8 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
|
17
19
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
18
20
|
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
19
21
|
const BEMClient = require('./client.js');
|
|
20
|
-
const
|
|
22
|
+
const builtinTools = require('./tools.js');
|
|
23
|
+
const { resolveAuthInfo, filterToolsByRole, loadConsumerTools, buildToolMap } = require('./utils.js');
|
|
21
24
|
const packageJSON = require('../../package.json');
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -25,6 +28,8 @@ const packageJSON = require('../../package.json');
|
|
|
25
28
|
* @param {object} options
|
|
26
29
|
* @param {string} options.baseUrl - BEM server URL
|
|
27
30
|
* @param {string} options.backendManagerKey - Admin API key
|
|
31
|
+
* @param {string} options.userToken - User API key (for user-level connections)
|
|
32
|
+
* @param {string} options.cwd - Consumer project functions directory (for consumer tool discovery)
|
|
28
33
|
*/
|
|
29
34
|
async function startServer(options) {
|
|
30
35
|
options = options || {};
|
|
@@ -35,12 +40,30 @@ async function startServer(options) {
|
|
|
35
40
|
const backendManagerKey = options.backendManagerKey
|
|
36
41
|
|| process.env.BACKEND_MANAGER_KEY
|
|
37
42
|
|| '';
|
|
43
|
+
const userToken = options.userToken || '';
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
// Determine auth role
|
|
46
|
+
const token = backendManagerKey || userToken || '';
|
|
47
|
+
const authInfo = resolveAuthInfo(token);
|
|
48
|
+
|
|
49
|
+
if (authInfo.role === 'public') {
|
|
50
|
+
console.error('[BEM MCP] No key or token set. Only public tools will be available.');
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
// Build client with appropriate auth
|
|
54
|
+
const client = new BEMClient({
|
|
55
|
+
baseUrl,
|
|
56
|
+
backendManagerKey: authInfo.role === 'admin' ? token : '',
|
|
57
|
+
userToken: authInfo.role === 'user' ? token : '',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Load and merge consumer tools (consumer overrides win)
|
|
61
|
+
const consumerTools = loadConsumerTools(options.cwd);
|
|
62
|
+
const toolMap = buildToolMap(builtinTools, consumerTools);
|
|
63
|
+
const allTools = Array.from(toolMap.values());
|
|
64
|
+
|
|
65
|
+
// Filter by role
|
|
66
|
+
const visibleTools = filterToolsByRole(allTools, authInfo.role);
|
|
44
67
|
|
|
45
68
|
// Create the MCP server
|
|
46
69
|
const server = new Server(
|
|
@@ -55,19 +78,15 @@ async function startServer(options) {
|
|
|
55
78
|
},
|
|
56
79
|
);
|
|
57
80
|
|
|
58
|
-
//
|
|
59
|
-
const toolMap = {};
|
|
60
|
-
for (const tool of tools) {
|
|
61
|
-
toolMap[tool.name] = tool;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Handle tools/list — return all tool definitions
|
|
81
|
+
// Handle tools/list — return role-filtered tool definitions
|
|
65
82
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
66
83
|
return {
|
|
67
|
-
tools:
|
|
84
|
+
tools: visibleTools.map((tool) => ({
|
|
68
85
|
name: tool.name,
|
|
69
86
|
description: tool.description,
|
|
70
87
|
inputSchema: tool.inputSchema,
|
|
88
|
+
outputSchema: tool.outputSchema,
|
|
89
|
+
annotations: tool.annotations,
|
|
71
90
|
})),
|
|
72
91
|
};
|
|
73
92
|
});
|
|
@@ -75,19 +94,26 @@ async function startServer(options) {
|
|
|
75
94
|
// Handle tools/call — execute the requested tool
|
|
76
95
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
77
96
|
const { name, arguments: args } = request.params;
|
|
78
|
-
const tool = toolMap
|
|
97
|
+
const tool = toolMap.get(name);
|
|
79
98
|
|
|
80
|
-
if (!tool) {
|
|
99
|
+
if (!tool || !visibleTools.some((t) => t.name === name)) {
|
|
81
100
|
return {
|
|
82
101
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
83
102
|
isError: true,
|
|
84
103
|
};
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
// Handler-based consumer tools require HTTP transport
|
|
107
|
+
if (tool.handler && !tool.path) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: `Tool "${name}" requires HTTP transport (handler-based tools cannot run over stdio).` }],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
try {
|
|
88
115
|
const response = await client.call(tool.method, tool.path, args || {});
|
|
89
116
|
|
|
90
|
-
// Format the response for the LLM
|
|
91
117
|
const text = typeof response === 'string'
|
|
92
118
|
? response
|
|
93
119
|
: JSON.stringify(response, null, 2);
|
|
@@ -113,7 +139,11 @@ async function startServer(options) {
|
|
|
113
139
|
|
|
114
140
|
// Log to stderr (stdout is reserved for MCP protocol)
|
|
115
141
|
console.error(`[BEM MCP] Server running — connected to ${baseUrl}`);
|
|
116
|
-
console.error(`[BEM MCP] ${
|
|
142
|
+
console.error(`[BEM MCP] Role: ${authInfo.role} | ${visibleTools.length}/${allTools.length} tools available`);
|
|
143
|
+
|
|
144
|
+
if (consumerTools.length > 0) {
|
|
145
|
+
console.error(`[BEM MCP] ${consumerTools.length} consumer tool(s) loaded`);
|
|
146
|
+
}
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
// Allow direct execution or require
|