backend-manager 5.6.4 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +4 -3
  3. package/PROGRESS.md +34 -0
  4. package/docs/ai-library.md +62 -11
  5. package/docs/cdp-debugging.md +44 -0
  6. package/docs/cli-output.md +22 -10
  7. package/docs/mcp.md +166 -43
  8. package/package.json +1 -1
  9. package/plans/mcp2.md +247 -0
  10. package/src/cli/commands/mcp.js +8 -2
  11. package/src/cli/commands/serve.js +155 -29
  12. package/src/cli/commands/setup-tests/base-test.js +8 -0
  13. package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
  14. package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
  15. package/src/cli/commands/setup-tests/index.js +4 -0
  16. package/src/cli/commands/setup-tests/java-installed.js +26 -0
  17. package/src/cli/commands/setup.js +2 -1
  18. package/src/cli/commands/test.js +8 -0
  19. package/src/cli/index.js +14 -0
  20. package/src/cli/utils/ui.js +27 -5
  21. package/src/manager/index.js +8 -3
  22. package/src/manager/libraries/ai/index.js +45 -1
  23. package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
  24. package/src/manager/libraries/ai/providers/anthropic.js +28 -49
  25. package/src/manager/libraries/ai/providers/claude-code.js +21 -47
  26. package/src/manager/libraries/ai/providers/openai.js +154 -19
  27. package/src/manager/libraries/ai/providers/test.js +242 -0
  28. package/src/manager/libraries/email/data/disposable-domains.json +465 -0
  29. package/src/mcp/client.js +48 -13
  30. package/src/mcp/handler.js +222 -69
  31. package/src/mcp/index.js +48 -18
  32. package/src/mcp/tools.js +150 -0
  33. package/src/mcp/utils.js +108 -0
  34. package/src/test/fixtures/firebase-project/firebase.json +1 -1
  35. package/test/ai/tools-live.js +170 -0
  36. package/test/helpers/ai-test-provider.js +202 -0
  37. package/test/helpers/ai-tools-format.js +350 -0
  38. package/test/mcp/discovery.js +53 -0
  39. package/test/mcp/oauth.js +161 -0
  40. package/test/mcp/protocol.js +268 -0
  41. package/test/mcp/roles.js +168 -0
  42. package/test/mcp/utils.js +245 -0
  43. 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 (method === 'GET') {
39
- // GET: auth + params go in query string
40
- url.searchParams.set('backendManagerKey', this.backendManagerKey);
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
- for (const [key, value] of Object.entries(params)) {
43
- if (value === undefined || value === null) {
44
- continue;
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
- // Serialize objects/arrays as JSON strings for query params
48
- url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
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
- // POST/PUT/DELETE: auth + params go in body
52
- fetchOptions.body = JSON.stringify({
53
- backendManagerKey: this.backendManagerKey,
54
- ...params,
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);
@@ -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 (wraps backendManagerKey as OAuth 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 tools = require('./tools.js');
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
- // Build tool lookup once
20
- const toolMap = {};
21
- for (const tool of tools) {
22
- toolMap[tool.name] = tool;
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: `${baseUrl}/backend-manager/mcp`,
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
- * If client_id matches the BEM key, auto-redirects immediately (no form).
86
- * Otherwise, shows a simple form to enter the key manually.
87
- *
88
- * To skip the manual step, set OAuth Client ID = YOUR_BEM_KEY in Claude Chat.
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 BEM key
96
- if (isValidKey(client_id) && redirect_uri) {
97
- const url = new URL(redirect_uri);
98
- url.searchParams.set('code', client_id);
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
- url.searchParams.set('state', state);
119
+ authUrl.searchParams.set('state', state);
101
120
  }
102
- res.writeHead(302, { Location: url.toString() });
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 (!isValidKey(key)) {
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
- const url = new URL(redirectUri);
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 the auth code (BEM key) for an access token (same BEM key)
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
- // The code, client_secret, or client_id IS the backendManagerKey — validate any
191
- if (!isValidKey(code)) {
192
- return sendJson(res, 401, {
193
- error: 'invalid_grant',
194
- error_description: 'Invalid authorization code.',
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
- // Return the key as the access token no storage needed
199
- sendJson(res, 200, {
200
- access_token: code,
201
- token_type: 'Bearer',
202
- scope: 'tools',
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
- // Authenticate via Bearer token
302
+ // Extract Bearer token
213
303
  const authHeader = req.headers.authorization || '';
214
- const key = authHeader.replace(/^Bearer\s+/i, '');
304
+ const token = authHeader.replace(/^Bearer\s+/i, '');
215
305
 
216
- if (!isValidKey(key)) {
217
- // Return 401 with OAuth discovery hint
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}/backend-manager/.well-known/oauth-protected-resource"`,
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
- // Determine the API URL for internal HTTP calls
243
- const apiUrl = Manager.project?.apiUrl || 'http://localhost:5002';
244
- const client = new BEMClient({ baseUrl: apiUrl, backendManagerKey: key });
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: tools.map((tool) => ({
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[name];
386
+ const tool = toolMap.get(name);
279
387
 
280
- if (!tool) {
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 ${tool.path}: ${message}` }],
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 tools = require('./tools.js');
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
- if (!backendManagerKey) {
40
- console.error('[BEM MCP] Warning: No BACKEND_MANAGER_KEY set. Admin routes will fail.');
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
- const client = new BEMClient({ baseUrl, backendManagerKey });
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
- // Build a lookup map for tool definitions
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: tools.map((tool) => ({
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[name];
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] ${tools.length} tools available`);
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