backend-manager 5.0.179 → 5.0.180

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.
@@ -0,0 +1,32 @@
1
+ As for the AMEX enrollment, AMEX alerts are not supported on Stripe/Shopify. To enable them, you’ll need a dedicated AMEX MID and a payment orchestrator to route all AMEX transactions through that dedicated MID. Using a dedicated AMEX MID allows for a dispute rate of up to 5%, so routing all AMEX traffic through it will significantly reduce your dispute rate on Stripe.
2
+
3
+ 06:44 PM
4
+ For Discover,
5
+
6
+ In order to enroll in Discover coverage, you will need to do the following:
7
+
8
+ First, provide us with details about your business below:
9
+ - Merchant Legal Name:
10
+ - Merchant DBA Name:
11
+ - Merchant Registered Street Address:
12
+ - City:
13
+ - Country:
14
+ - State/Province:
15
+ - ZIP/Postal Code:
16
+
17
+ Second, you must request from your payment processor’s support for a few pieces of information. To do so, please follow the steps below and send them the email template below.
18
+
19
+ ""I am enrolling for Discover Ethoca Alerts via my third-party vendor, Chargeblast. I need my 15-Digit Discover SE number. Can you please provide these pieces of information to me ASAP? Let me know if you need any additional info from me. Please feel free to provide me with these pieces of information piecemeal.”
20
+
21
+ 06:44 PM
22
+ For AMEX If you’d like to proceed with obtaining a dedicated AMEX MID, we’d be happy to arrange an introduction for you.
23
+
24
+ 06:45 PM
25
+ Avatar of Zander
26
+ Zander
27
+ Are we still connected?
28
+
29
+ 06:56 PM
30
+ Avatar of Zander
31
+ Zander
32
+ Since this chat has been inactive, I’ll close it for now. If you have more questions, feel free to contact us at any time. Have a great day ahead!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.179",
3
+ "version": "5.0.180",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -48,6 +48,7 @@
48
48
  "@firebase/rules-unit-testing": "^5.0.0",
49
49
  "@google-cloud/pubsub": "^5.3.0",
50
50
  "@google-cloud/storage": "^7.19.0",
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
52
  "@octokit/rest": "^19.0.13",
52
53
  "@sendgrid/mail": "^8.1.6",
53
54
  "@sentry/node": "^6.19.7",
@@ -0,0 +1,32 @@
1
+ const path = require('path');
2
+ const BaseCommand = require('./base-command');
3
+
4
+ class McpCommand extends BaseCommand {
5
+ async execute() {
6
+ const self = this;
7
+ const functionsDir = path.join(self.firebaseProjectPath, 'functions');
8
+
9
+ // Load .env from functions directory so BACKEND_MANAGER_KEY is available
10
+ const jetpack = require('fs-jetpack');
11
+ const envPath = path.join(functionsDir, '.env');
12
+ if (jetpack.exists(envPath)) {
13
+ require('dotenv').config({ path: envPath });
14
+ }
15
+
16
+ // Resolve the BEM server URL
17
+ const baseUrl = self.argv.url
18
+ || process.env.BEM_URL
19
+ || 'http://localhost:5002';
20
+
21
+ // Resolve the admin key
22
+ const backendManagerKey = self.argv.key
23
+ || process.env.BACKEND_MANAGER_KEY
24
+ || '';
25
+
26
+ const { startServer } = require('../../mcp/index.js');
27
+
28
+ await startServer({ baseUrl, backendManagerKey });
29
+ }
30
+ }
31
+
32
+ module.exports = McpCommand;
package/src/cli/index.js CHANGED
@@ -29,6 +29,7 @@ const StripeCommand = require('./commands/stripe');
29
29
  const FirestoreCommand = require('./commands/firestore');
30
30
  const AuthCommand = require('./commands/auth');
31
31
  const LogsCommand = require('./commands/logs');
32
+ const McpCommand = require('./commands/mcp');
32
33
 
33
34
  function Main() {}
34
35
 
@@ -152,6 +153,12 @@ Main.prototype.process = async function (args) {
152
153
  const cmd = new LogsCommand(self);
153
154
  return await cmd.execute();
154
155
  }
156
+
157
+ // MCP server
158
+ if (self.options['mcp']) {
159
+ const cmd = new McpCommand(self);
160
+ return await cmd.execute();
161
+ }
155
162
  };
156
163
 
157
164
  // Test method for setup command
@@ -1,5 +1,5 @@
1
1
  const ERROR_TOO_MANY_ATTEMPTS = 'You have created too many accounts with our service. Please try again later.';
2
- const MAX_SIGNUPS_PER_DAY = 3;
2
+ const MAX_SIGNUPS_PER_DAY = 2;
3
3
 
4
4
  /**
5
5
  * beforeUserCreated - IP Rate Limiting ONLY
@@ -431,6 +431,19 @@ Manager.prototype._preProcess = function (mod) {
431
431
  });
432
432
  };
433
433
 
434
+ Manager.prototype._handleMcp = function (req, res, routePath) {
435
+ const self = this;
436
+ const cors = self.libraries.cors;
437
+
438
+ return cors(req, res, async () => {
439
+ const { handleMcpRoute } = require('../mcp/handler.js');
440
+ await handleMcpRoute(req, res, {
441
+ Manager: self,
442
+ routePath: routePath,
443
+ });
444
+ });
445
+ };
446
+
434
447
  Manager.prototype._processMiddleware = function (req, res, routePath) {
435
448
  const self = this;
436
449
 
@@ -779,6 +792,14 @@ Manager.prototype.setupFunctions = function (exporter, options) {
779
792
  .https.onRequest(async (req, res) => {
780
793
  const route = self.BemRouter(req, res).resolve();
781
794
 
795
+ // MCP endpoint — bypass middleware, handle protocol directly
796
+ if (route.routePath === 'mcp'
797
+ || route.routePath.startsWith('mcp/')
798
+ || route.routePath === '.well-known/oauth-protected-resource'
799
+ || route.routePath === '.well-known/oauth-authorization-server') {
800
+ return self._handleMcp(req, res, route.routePath);
801
+ }
802
+
782
803
  if (route.isLegacy) {
783
804
  // Legacy command-based API -> goes through api.js + _process() for hooks
784
805
  return self._process((new (require(`${core}/actions/api.js`))()).init(self, { req, res }));
@@ -4,6 +4,7 @@
4
4
  "0815.ru",
5
5
  "0clickemail.com",
6
6
  "10mail.org",
7
+ "10mail.xyz",
7
8
  "10minutemail.com",
8
9
  "10minutemail.net",
9
10
  "123-m.com",
@@ -47,8 +48,8 @@
47
48
  "9mail.cf",
48
49
  "a-bc.net",
49
50
  "agedmail.com",
50
- "aji.kr",
51
51
  "ajaxapp.net",
52
+ "aji.kr",
52
53
  "alivance.com",
53
54
  "amilegit.com",
54
55
  "amiri.net",
@@ -186,6 +187,9 @@
186
187
  "emailxfer.com",
187
188
  "emeil.in",
188
189
  "emeil.ir",
190
+ "emlhub.com",
191
+ "emlpro.com",
192
+ "emltmp.com",
189
193
  "emz.net",
190
194
  "enterto.com",
191
195
  "ephemail.net",
@@ -219,10 +223,12 @@
219
223
  "flyspam.com",
220
224
  "fr33mail.info",
221
225
  "frapmail.com",
226
+ "freeml.net",
222
227
  "friendlymail.co.uk",
223
228
  "front14.org",
224
229
  "fuckingduh.com",
225
230
  "fudgerub.com",
231
+ "fun4k.com",
226
232
  "fux0ringduh.com",
227
233
  "garliclife.com",
228
234
  "gehensiull.com",
@@ -384,8 +390,8 @@
384
390
  "maildrop.ml",
385
391
  "maildu.de",
386
392
  "maildx.com",
387
- "mailed.ro",
388
393
  "maileater.com",
394
+ "mailed.ro",
389
395
  "mailexpire.com",
390
396
  "mailfa.tk",
391
397
  "mailforspam.com",
@@ -440,6 +446,7 @@
440
446
  "mailzilla.org",
441
447
  "makemetheking.com",
442
448
  "manybrain.com",
449
+ "maximail.fyi",
443
450
  "mbx.cc",
444
451
  "mega.zik.dj",
445
452
  "meinspamschutz.de",
@@ -447,7 +454,9 @@
447
454
  "messagebeamer.de",
448
455
  "mezimages.net",
449
456
  "mierdamail.com",
457
+ "mimimail.me",
450
458
  "ministry-of-silly-walks.de",
459
+ "minitts.net",
451
460
  "mintemail.com",
452
461
  "misterpinball.de",
453
462
  "mmmmail.com",
@@ -503,6 +512,7 @@
503
512
  "nurfuerspam.de",
504
513
  "nus.edu.sg",
505
514
  "nwldx.com",
515
+ "oakon.com",
506
516
  "objectmail.com",
507
517
  "obobbo.com",
508
518
  "odnorazovoe.ru",
@@ -520,6 +530,9 @@
520
530
  "ovpn.to",
521
531
  "owlpic.com",
522
532
  "pancakemail.com",
533
+ "pastryofistanbul.com",
534
+ "pickmail.org",
535
+ "pickmemail.com",
523
536
  "pjjkp.com",
524
537
  "plexolan.de",
525
538
  "poczta.onet.pl",
@@ -568,6 +581,7 @@
568
581
  "selfdestructingmail.com",
569
582
  "senseless-entertainment.com",
570
583
  "server.ms.selfip.net",
584
+ "sharebot.net",
571
585
  "sharklasers.com",
572
586
  "shieldedmail.com",
573
587
  "shieldemail.com",
@@ -655,6 +669,7 @@
655
669
  "spamtroll.net",
656
670
  "speed.1s.fr",
657
671
  "spoofmail.de",
672
+ "spymail.one",
658
673
  "squizzy.de",
659
674
  "ssoia.com",
660
675
  "startkeys.com",
@@ -680,6 +695,7 @@
680
695
  "talkinator.com",
681
696
  "tapchicuoihoi.com",
682
697
  "teewars.org",
698
+ "teihu.com",
683
699
  "teleosaurs.xyz",
684
700
  "teleworm.com",
685
701
  "teleworm.us",
@@ -720,8 +736,8 @@
720
736
  "throwawaymail.com",
721
737
  "tilien.com",
722
738
  "tittbit.in",
723
- "tmailinator.com",
724
739
  "tmail.ws",
740
+ "tmailinator.com",
725
741
  "toiea.com",
726
742
  "toomail.biz",
727
743
  "topranklist.de",
@@ -817,6 +833,7 @@
817
833
  "yeah.net",
818
834
  "yep.it",
819
835
  "yogamaven.com",
836
+ "yomail.info",
820
837
  "yopmail.com",
821
838
  "yopmail.fr",
822
839
  "yopmail.gq",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * BEM HTTP Client
3
+ *
4
+ * Makes authenticated HTTP calls to a running BEM server (local or production).
5
+ */
6
+ const fetch = require('wonderful-fetch');
7
+
8
+ class BEMClient {
9
+ constructor(options) {
10
+ options = options || {};
11
+
12
+ this.baseUrl = (options.baseUrl || '').replace(/\/+$/, '');
13
+ this.backendManagerKey = options.backendManagerKey || '';
14
+ }
15
+
16
+ /**
17
+ * Call a BEM route
18
+ * @param {string} method - HTTP method (GET, POST, PUT, DELETE)
19
+ * @param {string} path - Route path (e.g. "admin/firestore")
20
+ * @param {object} params - Request parameters
21
+ * @returns {object} - Parsed response
22
+ */
23
+ async call(method, path, params) {
24
+ params = params || {};
25
+ method = method.toUpperCase();
26
+
27
+ const url = new URL(`${this.baseUrl}/backend-manager/${path}`);
28
+
29
+ const fetchOptions = {
30
+ method: method,
31
+ response: 'json',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ },
35
+ timeout: 120000,
36
+ };
37
+
38
+ if (method === 'GET') {
39
+ // GET: auth + params go in query string
40
+ url.searchParams.set('backendManagerKey', this.backendManagerKey);
41
+
42
+ for (const [key, value] of Object.entries(params)) {
43
+ if (value === undefined || value === null) {
44
+ continue;
45
+ }
46
+
47
+ // Serialize objects/arrays as JSON strings for query params
48
+ url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
49
+ }
50
+ } else {
51
+ // POST/PUT/DELETE: auth + params go in body
52
+ fetchOptions.body = JSON.stringify({
53
+ backendManagerKey: this.backendManagerKey,
54
+ ...params,
55
+ });
56
+ }
57
+
58
+ const response = await fetch(url.toString(), fetchOptions);
59
+
60
+ return response;
61
+ }
62
+ }
63
+
64
+ module.exports = BEMClient;
@@ -0,0 +1,342 @@
1
+ /**
2
+ * MCP HTTP Handler (Stateless + OAuth)
3
+ *
4
+ * Routes all MCP-related requests:
5
+ * - OAuth discovery (.well-known endpoints)
6
+ * - OAuth authorize + token (wraps backendManagerKey as OAuth token)
7
+ * - MCP protocol (stateless Streamable HTTP transport)
8
+ *
9
+ * Compatible with serverless environments like Firebase Functions.
10
+ * No tokens stored — the backendManagerKey IS the access token.
11
+ */
12
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
13
+ const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
14
+ const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
15
+ const tools = require('./tools.js');
16
+ const BEMClient = require('./client.js');
17
+ const packageJSON = require('../../package.json');
18
+
19
+ // Build tool lookup once
20
+ const toolMap = {};
21
+ for (const tool of tools) {
22
+ toolMap[tool.name] = tool;
23
+ }
24
+
25
+ /**
26
+ * Route all MCP-related requests
27
+ *
28
+ * @param {IncomingMessage} req
29
+ * @param {ServerResponse} res
30
+ * @param {object} options
31
+ * @param {object} options.Manager - BEM Manager instance
32
+ * @param {string} options.routePath - Resolved route path (e.g. "mcp", "mcp/authorize")
33
+ */
34
+ async function handleMcpRoute(req, res, options) {
35
+ 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
+ const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
39
+ const host = req.headers['x-forwarded-host'] || req.headers.host || '';
40
+ const baseUrl = `${protocol}://${host}`;
41
+
42
+ // --- OAuth Discovery ---
43
+ if (routePath === '.well-known/oauth-protected-resource') {
44
+ return sendJson(res, 200, {
45
+ resource: `${baseUrl}/backend-manager/mcp`,
46
+ authorization_servers: [
47
+ `${baseUrl}/backend-manager/mcp`,
48
+ ],
49
+ });
50
+ }
51
+
52
+ if (routePath === '.well-known/oauth-authorization-server') {
53
+ return sendJson(res, 200, {
54
+ issuer: `${baseUrl}/backend-manager/mcp`,
55
+ authorization_endpoint: `${baseUrl}/backend-manager/mcp/authorize`,
56
+ token_endpoint: `${baseUrl}/backend-manager/mcp/token`,
57
+ response_types_supported: ['code'],
58
+ grant_types_supported: ['authorization_code'],
59
+ code_challenge_methods_supported: ['S256'],
60
+ token_endpoint_auth_methods_supported: ['none'],
61
+ });
62
+ }
63
+
64
+ // --- OAuth Authorize ---
65
+ if (routePath === 'mcp/authorize') {
66
+ return handleAuthorize(req, res, options);
67
+ }
68
+
69
+ // --- OAuth Token ---
70
+ if (routePath === 'mcp/token') {
71
+ return handleToken(req, res, options);
72
+ }
73
+
74
+ // --- MCP Protocol ---
75
+ if (routePath === 'mcp') {
76
+ return handleMcpProtocol(req, res, options);
77
+ }
78
+
79
+ sendJson(res, 404, { error: 'Not found' });
80
+ }
81
+
82
+ /**
83
+ * OAuth Authorize
84
+ *
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.
89
+ */
90
+ function handleAuthorize(req, res, options) {
91
+ const query = req.query || {};
92
+ const { redirect_uri, state, client_id } = query;
93
+ const Manager = options.Manager;
94
+
95
+ // Auto-approve if client_id matches the BEM key
96
+ if (isValidKey(client_id, Manager) && redirect_uri) {
97
+ const url = new URL(redirect_uri);
98
+ url.searchParams.set('code', client_id);
99
+ if (state) {
100
+ url.searchParams.set('state', state);
101
+ }
102
+ res.writeHead(302, { Location: url.toString() });
103
+ res.end();
104
+ return;
105
+ }
106
+
107
+ if (req.method === 'GET') {
108
+ // Show a simple authorize form (fallback when client_id is not the BEM key)
109
+ const html = `<!DOCTYPE html>
110
+ <html>
111
+ <head>
112
+ <title>Backend Manager — Authorize MCP</title>
113
+ <meta name="viewport" content="width=device-width, initial-scale=1">
114
+ <style>
115
+ * { box-sizing: border-box; margin: 0; padding: 0; }
116
+ body { font-family: -apple-system, system-ui, sans-serif; background: #111; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
117
+ .card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 32px; max-width: 420px; width: 100%; }
118
+ h1 { font-size: 20px; margin-bottom: 8px; }
119
+ p { font-size: 14px; color: #999; margin-bottom: 24px; }
120
+ label { font-size: 13px; color: #aaa; display: block; margin-bottom: 6px; }
121
+ input[type="password"] { width: 100%; padding: 10px 12px; background: #222; border: 1px solid #444; border-radius: 6px; color: #eee; font-size: 14px; }
122
+ input[type="password"]:focus { outline: none; border-color: #7c6df0; }
123
+ button { margin-top: 20px; width: 100%; padding: 10px; background: #7c6df0; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
124
+ button:hover { background: #6b5de0; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <div class="card">
129
+ <h1>Authorize MCP Connection</h1>
130
+ <p>Enter your Backend Manager key to allow Claude to connect.</p>
131
+ <form method="POST">
132
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri || '')}">
133
+ <input type="hidden" name="state" value="${escapeHtml(state || '')}">
134
+ <label for="key">Backend Manager Key</label>
135
+ <input type="password" id="key" name="key" placeholder="Enter your key" required autofocus>
136
+ <button type="submit">Allow</button>
137
+ </form>
138
+ </div>
139
+ </body>
140
+ </html>`;
141
+
142
+ res.writeHead(200, { 'Content-Type': 'text/html' });
143
+ res.end(html);
144
+ return;
145
+ }
146
+
147
+ // POST — validate key and redirect back with code
148
+ if (req.method === 'POST') {
149
+ const body = req.body || {};
150
+ const key = body.key || '';
151
+ const redirectUri = body.redirect_uri || '';
152
+ const postState = body.state || '';
153
+
154
+ if (!isValidKey(key, Manager)) {
155
+ res.writeHead(403, { 'Content-Type': 'text/html' });
156
+ 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
+ return;
158
+ }
159
+
160
+ if (!redirectUri) {
161
+ return sendJson(res, 400, { error: 'Missing redirect_uri' });
162
+ }
163
+
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;
173
+ }
174
+
175
+ sendJson(res, 405, { error: 'Method not allowed' });
176
+ }
177
+
178
+ /**
179
+ * OAuth Token — exchanges the auth code (BEM key) for an access token (same BEM key)
180
+ */
181
+ function handleToken(req, res, options) {
182
+ if (req.method !== 'POST') {
183
+ return sendJson(res, 405, { error: 'Method not allowed' });
184
+ }
185
+
186
+ const body = req.body || {};
187
+ const code = body.code || body.client_secret || body.client_id || '';
188
+ const Manager = options.Manager;
189
+
190
+ // The code, client_secret, or client_id IS the backendManagerKey — validate any
191
+ if (!isValidKey(code, Manager)) {
192
+ return sendJson(res, 401, {
193
+ error: 'invalid_grant',
194
+ error_description: 'Invalid authorization code.',
195
+ });
196
+ }
197
+
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',
203
+ });
204
+ }
205
+
206
+ /**
207
+ * MCP Protocol — stateless Streamable HTTP transport
208
+ */
209
+ async function handleMcpProtocol(req, res, options) {
210
+ const { Manager } = options;
211
+
212
+ // Authenticate via Bearer token
213
+ const authHeader = req.headers.authorization || '';
214
+ const key = authHeader.replace(/^Bearer\s+/i, '');
215
+
216
+ if (!isValidKey(key, Manager)) {
217
+ // Return 401 with OAuth discovery hint
218
+ const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
219
+ const host = req.headers['x-forwarded-host'] || req.headers.host || '';
220
+ const baseUrl = `${protocol}://${host}`;
221
+ res.writeHead(401, {
222
+ 'Content-Type': 'application/json',
223
+ 'WWW-Authenticate': `Bearer resource_metadata="${baseUrl}/backend-manager/.well-known/oauth-protected-resource"`,
224
+ });
225
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
226
+ return;
227
+ }
228
+
229
+ // Only POST supported in stateless mode
230
+ if (req.method !== 'POST') {
231
+ if (req.method === 'DELETE') {
232
+ res.writeHead(200);
233
+ res.end();
234
+ return;
235
+ }
236
+ return sendJson(res, 405, {
237
+ jsonrpc: '2.0',
238
+ error: { code: -32000, message: 'Method not allowed. Use POST.' },
239
+ });
240
+ }
241
+
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 });
245
+
246
+ // Create a fresh stateless transport
247
+ const transport = new StreamableHTTPServerTransport({
248
+ sessionIdGenerator: undefined,
249
+ });
250
+
251
+ // Create MCP server
252
+ const server = new Server(
253
+ {
254
+ name: 'backend-manager',
255
+ version: packageJSON.version,
256
+ },
257
+ {
258
+ capabilities: {
259
+ tools: {},
260
+ },
261
+ },
262
+ );
263
+
264
+ // List tools
265
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
266
+ return {
267
+ tools: tools.map((tool) => ({
268
+ name: tool.name,
269
+ description: tool.description,
270
+ inputSchema: tool.inputSchema,
271
+ })),
272
+ };
273
+ });
274
+
275
+ // Call tools
276
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
277
+ const { name, arguments: args } = request.params;
278
+ const tool = toolMap[name];
279
+
280
+ if (!tool) {
281
+ return {
282
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
283
+ isError: true,
284
+ };
285
+ }
286
+
287
+ try {
288
+ const response = await client.call(tool.method, tool.path, args || {});
289
+
290
+ const text = typeof response === 'string'
291
+ ? response
292
+ : JSON.stringify(response, null, 2);
293
+
294
+ return {
295
+ content: [{ type: 'text', text }],
296
+ };
297
+ } catch (error) {
298
+ const message = error.response
299
+ ? JSON.stringify(error.response, null, 2)
300
+ : error.message;
301
+
302
+ return {
303
+ content: [{ type: 'text', text: `Error calling ${tool.path}: ${message}` }],
304
+ isError: true,
305
+ };
306
+ }
307
+ });
308
+
309
+ // Connect and handle
310
+ await server.connect(transport);
311
+ await transport.handleRequest(req, res, req.body);
312
+
313
+ // Clean up
314
+ await transport.close();
315
+ await server.close();
316
+ }
317
+
318
+ // --- Helpers ---
319
+
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, Manager) {
325
+ const configKey = Manager.config?.backendManagerKey;
326
+ return !!key && !!configKey && key === configKey;
327
+ }
328
+
329
+ function sendJson(res, code, data) {
330
+ res.writeHead(code, { 'Content-Type': 'application/json' });
331
+ res.end(JSON.stringify(data));
332
+ }
333
+
334
+ function escapeHtml(str) {
335
+ return str
336
+ .replace(/&/g, '&amp;')
337
+ .replace(/</g, '&lt;')
338
+ .replace(/>/g, '&gt;')
339
+ .replace(/"/g, '&quot;');
340
+ }
341
+
342
+ module.exports = { handleMcpRoute };
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * BEM MCP Server
5
+ *
6
+ * Exposes Backend Manager routes as MCP tools so Claude (or any MCP client)
7
+ * can interact with a running BEM instance — local or production.
8
+ *
9
+ * Usage:
10
+ * npx bm mcp
11
+ *
12
+ * Environment variables:
13
+ * BEM_URL - BEM server URL (default: http://localhost:5002)
14
+ * BACKEND_MANAGER_KEY - Admin API key for authentication
15
+ */
16
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
17
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
18
+ const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
19
+ const BEMClient = require('./client.js');
20
+ const tools = require('./tools.js');
21
+ const packageJSON = require('../../package.json');
22
+
23
+ /**
24
+ * Start the MCP server
25
+ * @param {object} options
26
+ * @param {string} options.baseUrl - BEM server URL
27
+ * @param {string} options.backendManagerKey - Admin API key
28
+ */
29
+ async function startServer(options) {
30
+ options = options || {};
31
+
32
+ const baseUrl = options.baseUrl
33
+ || process.env.BEM_URL
34
+ || 'http://localhost:5002';
35
+ const backendManagerKey = options.backendManagerKey
36
+ || process.env.BACKEND_MANAGER_KEY
37
+ || '';
38
+
39
+ if (!backendManagerKey) {
40
+ console.error('[BEM MCP] Warning: No BACKEND_MANAGER_KEY set. Admin routes will fail.');
41
+ }
42
+
43
+ const client = new BEMClient({ baseUrl, backendManagerKey });
44
+
45
+ // Create the MCP server
46
+ const server = new Server(
47
+ {
48
+ name: 'backend-manager',
49
+ version: packageJSON.version,
50
+ },
51
+ {
52
+ capabilities: {
53
+ tools: {},
54
+ },
55
+ },
56
+ );
57
+
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
65
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
66
+ return {
67
+ tools: tools.map((tool) => ({
68
+ name: tool.name,
69
+ description: tool.description,
70
+ inputSchema: tool.inputSchema,
71
+ })),
72
+ };
73
+ });
74
+
75
+ // Handle tools/call — execute the requested tool
76
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
77
+ const { name, arguments: args } = request.params;
78
+ const tool = toolMap[name];
79
+
80
+ if (!tool) {
81
+ return {
82
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
83
+ isError: true,
84
+ };
85
+ }
86
+
87
+ try {
88
+ const response = await client.call(tool.method, tool.path, args || {});
89
+
90
+ // Format the response for the LLM
91
+ const text = typeof response === 'string'
92
+ ? response
93
+ : JSON.stringify(response, null, 2);
94
+
95
+ return {
96
+ content: [{ type: 'text', text }],
97
+ };
98
+ } catch (error) {
99
+ const message = error.response
100
+ ? JSON.stringify(error.response, null, 2)
101
+ : error.message;
102
+
103
+ return {
104
+ content: [{ type: 'text', text: `Error calling ${tool.path}: ${message}` }],
105
+ isError: true,
106
+ };
107
+ }
108
+ });
109
+
110
+ // Connect via stdio transport
111
+ const transport = new StdioServerTransport();
112
+ await server.connect(transport);
113
+
114
+ // Log to stderr (stdout is reserved for MCP protocol)
115
+ console.error(`[BEM MCP] Server running — connected to ${baseUrl}`);
116
+ console.error(`[BEM MCP] ${tools.length} tools available`);
117
+ }
118
+
119
+ // Allow direct execution or require
120
+ if (require.main === module) {
121
+ startServer().catch((error) => {
122
+ console.error('[BEM MCP] Fatal error:', error);
123
+ process.exit(1);
124
+ });
125
+ }
126
+
127
+ module.exports = { startServer };
@@ -0,0 +1,359 @@
1
+ /**
2
+ * MCP Tool Definitions
3
+ *
4
+ * Each tool maps to a BEM route with method, path, and JSON Schema for inputs.
5
+ */
6
+ module.exports = [
7
+ // --- Firestore ---
8
+ {
9
+ name: 'firestore_read',
10
+ description: 'Read a Firestore document by path (e.g. "users/abc123")',
11
+ method: 'GET',
12
+ path: 'admin/firestore',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ path: { type: 'string', description: 'Firestore document path (e.g. "users/abc123")' },
17
+ },
18
+ required: ['path'],
19
+ },
20
+ },
21
+ {
22
+ name: 'firestore_write',
23
+ description: 'Write/merge a Firestore document. Set merge=false to overwrite entirely.',
24
+ method: 'POST',
25
+ path: 'admin/firestore',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ path: { type: 'string', description: 'Firestore document path (e.g. "users/abc123")' },
30
+ document: { type: 'object', description: 'Document data to write' },
31
+ merge: { type: 'boolean', description: 'Merge with existing document (default: true)', default: true },
32
+ },
33
+ required: ['path', 'document'],
34
+ },
35
+ },
36
+ {
37
+ name: 'firestore_query',
38
+ description: 'Query a Firestore collection with where clauses, ordering, and limits. Each query in the array has: collection (string), where (array of {field, operator, value}), orderBy (array of {field, order}), limit (number).',
39
+ method: 'POST',
40
+ path: 'admin/firestore/query',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ queries: {
45
+ type: 'array',
46
+ description: 'Array of query objects',
47
+ items: {
48
+ type: 'object',
49
+ properties: {
50
+ collection: { type: 'string', description: 'Collection path (e.g. "users")' },
51
+ where: {
52
+ type: 'array',
53
+ description: 'Where clauses',
54
+ items: {
55
+ type: 'object',
56
+ properties: {
57
+ field: { type: 'string' },
58
+ operator: { type: 'string', description: 'Firestore operator: ==, !=, <, <=, >, >=, in, not-in, array-contains, array-contains-any' },
59
+ value: { description: 'Value to compare against' },
60
+ },
61
+ required: ['field', 'operator', 'value'],
62
+ },
63
+ },
64
+ orderBy: {
65
+ type: 'array',
66
+ description: 'Order by clauses',
67
+ items: {
68
+ type: 'object',
69
+ properties: {
70
+ field: { type: 'string' },
71
+ order: { type: 'string', enum: ['asc', 'desc'], default: 'asc' },
72
+ },
73
+ required: ['field'],
74
+ },
75
+ },
76
+ limit: { type: 'number', description: 'Max documents to return' },
77
+ },
78
+ required: ['collection'],
79
+ },
80
+ },
81
+ },
82
+ required: ['queries'],
83
+ },
84
+ },
85
+
86
+ // --- Email ---
87
+ {
88
+ name: 'send_email',
89
+ description: 'Send a transactional email via SendGrid. Recipients can be email strings, UIDs (auto-resolves from Firestore), or {email, name} objects.',
90
+ method: 'POST',
91
+ path: 'admin/email',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ to: { description: 'Recipient(s): email string, UID string, {email, name} object, or array of any' },
96
+ cc: { description: 'CC recipient(s): same formats as "to"' },
97
+ bcc: { description: 'BCC recipient(s): same formats as "to"' },
98
+ subject: { type: 'string', description: 'Email subject line' },
99
+ template: { type: 'string', description: 'SendGrid template ID or name' },
100
+ html: { type: 'string', description: 'Raw HTML body (alternative to template)' },
101
+ data: { type: 'object', description: 'Template variables / dynamic data' },
102
+ sender: { type: 'string', description: 'Sender preset name (e.g. "marketing", "support")' },
103
+ group: { description: 'Unsubscribe group ID (number or string)' },
104
+ categories: { type: 'array', items: { type: 'string' }, description: 'Email categories for tracking' },
105
+ },
106
+ required: ['to'],
107
+ },
108
+ },
109
+
110
+ // --- Notifications ---
111
+ {
112
+ name: 'send_notification',
113
+ description: 'Send a push notification via FCM to users or topics',
114
+ method: 'POST',
115
+ path: 'admin/notification',
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ notification: {
120
+ type: 'object',
121
+ description: 'Notification content',
122
+ properties: {
123
+ title: { type: 'string', description: 'Notification title' },
124
+ body: { type: 'string', description: 'Notification body text' },
125
+ },
126
+ required: ['title', 'body'],
127
+ },
128
+ filters: {
129
+ type: 'object',
130
+ description: 'Targeting filters',
131
+ properties: {
132
+ tags: { description: 'Filter by tags' },
133
+ owner: { type: 'string', description: 'Target specific user UID' },
134
+ token: { type: 'string', description: 'Target specific FCM token' },
135
+ limit: { type: 'number', description: 'Max recipients' },
136
+ },
137
+ },
138
+ },
139
+ required: ['notification'],
140
+ },
141
+ },
142
+
143
+ // --- User Management ---
144
+ {
145
+ name: 'get_user',
146
+ description: 'Get the currently authenticated user info. To look up a specific user, use firestore_read with path "users/{uid}" instead.',
147
+ method: 'GET',
148
+ path: 'user',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {},
152
+ },
153
+ },
154
+ {
155
+ name: 'get_subscription',
156
+ description: 'Get subscription info for a user. Defaults to the authenticated user, or pass a uid to look up another user (admin only).',
157
+ method: 'GET',
158
+ path: 'user/subscription',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ uid: { type: 'string', description: 'User UID to look up (admin only, defaults to authenticated user)' },
163
+ },
164
+ },
165
+ },
166
+ {
167
+ name: 'sync_users',
168
+ description: 'Sync user data across systems (marketing contacts, etc). Processes users in batches.',
169
+ method: 'POST',
170
+ path: 'admin/users/sync',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {},
174
+ },
175
+ },
176
+
177
+ // --- Marketing Campaigns ---
178
+ {
179
+ name: 'list_campaigns',
180
+ description: 'List marketing campaigns with optional filters by date range, status, and type',
181
+ method: 'GET',
182
+ path: 'marketing/campaign',
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ id: { type: 'string', description: 'Get a specific campaign by ID' },
187
+ start: { description: 'Start date filter (ISO string or unix timestamp)' },
188
+ end: { description: 'End date filter (ISO string or unix timestamp)' },
189
+ status: { type: 'string', description: 'Filter by status: pending, sent, failed' },
190
+ type: { type: 'string', description: 'Filter by type: email, push' },
191
+ limit: { type: 'number', description: 'Max results (default: 100)' },
192
+ },
193
+ },
194
+ },
195
+ {
196
+ name: 'create_campaign',
197
+ description: 'Create a marketing campaign (email or push notification). Can be immediate or scheduled.',
198
+ method: 'POST',
199
+ path: 'marketing/campaign',
200
+ inputSchema: {
201
+ type: 'object',
202
+ properties: {
203
+ name: { type: 'string', description: 'Campaign name' },
204
+ subject: { type: 'string', description: 'Email subject line' },
205
+ type: { type: 'string', enum: ['email', 'push'], default: 'email', description: 'Campaign type' },
206
+ preheader: { type: 'string', description: 'Email preheader text' },
207
+ content: { type: 'string', description: 'Campaign content (markdown)' },
208
+ template: { type: 'string', description: 'Email template name', default: 'default' },
209
+ segments: { type: 'array', items: { type: 'string' }, description: 'Target segment keys (e.g. ["subscription_free"])' },
210
+ excludeSegments: { type: 'array', items: { type: 'string' }, description: 'Exclude segment keys' },
211
+ all: { type: 'boolean', description: 'Send to all contacts (overrides segments)' },
212
+ sendAt: { description: 'Schedule time (ISO string or unix timestamp). Omit for immediate.' },
213
+ sender: { type: 'string', description: 'Sender preset name', default: 'marketing' },
214
+ test: { type: 'boolean', description: 'Send as test (to sender only)', default: false },
215
+ data: { type: 'object', description: 'Template variables' },
216
+ },
217
+ required: ['name', 'subject'],
218
+ },
219
+ },
220
+
221
+ // --- Stats ---
222
+ {
223
+ name: 'get_stats',
224
+ description: 'Get system statistics (user counts, subscription metrics, etc.)',
225
+ method: 'GET',
226
+ path: 'admin/stats',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ update: { description: 'Pass true to force recalculation, or an object for specific stat options' },
231
+ },
232
+ },
233
+ },
234
+
235
+ // --- Payments ---
236
+ {
237
+ name: 'cancel_subscription',
238
+ description: 'Cancel a subscription at the end of the current billing period. Requires the authenticated user to have an active subscription.',
239
+ method: 'POST',
240
+ path: 'payments/cancel',
241
+ inputSchema: {
242
+ type: 'object',
243
+ properties: {
244
+ reason: { type: 'string', description: 'Cancellation reason' },
245
+ feedback: { type: 'string', description: 'Additional feedback' },
246
+ confirmed: { type: 'boolean', description: 'Must be true to confirm cancellation' },
247
+ },
248
+ required: ['confirmed'],
249
+ },
250
+ },
251
+ {
252
+ name: 'refund_payment',
253
+ description: 'Process a refund for a subscription. Immediately cancels and refunds the latest payment.',
254
+ method: 'POST',
255
+ path: 'payments/refund',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ reason: { type: 'string', description: 'Refund reason (required)' },
260
+ feedback: { type: 'string', description: 'Additional feedback' },
261
+ confirmed: { type: 'boolean', description: 'Must be true to confirm refund' },
262
+ },
263
+ required: ['reason', 'confirmed'],
264
+ },
265
+ },
266
+
267
+ // --- Cron ---
268
+ {
269
+ name: 'run_cron',
270
+ description: 'Manually trigger a cron job by ID (e.g. "daily", "reset-usage", "marketing-campaigns")',
271
+ method: 'POST',
272
+ path: 'admin/cron',
273
+ inputSchema: {
274
+ type: 'object',
275
+ properties: {
276
+ id: { type: 'string', description: 'Cron job ID to trigger' },
277
+ },
278
+ required: ['id'],
279
+ },
280
+ },
281
+
282
+ // --- Blog Posts ---
283
+ {
284
+ name: 'create_post',
285
+ description: 'Create a blog post. Handles image downloading, GitHub upload, and body rewriting.',
286
+ method: 'POST',
287
+ path: 'admin/post',
288
+ inputSchema: {
289
+ type: 'object',
290
+ properties: {
291
+ title: { type: 'string', description: 'Post title' },
292
+ body: { type: 'string', description: 'Post body (markdown)' },
293
+ tags: { type: 'array', items: { type: 'string' }, description: 'Post tags' },
294
+ categories: { type: 'array', items: { type: 'string' }, description: 'Post categories' },
295
+ headerImageURL: { type: 'string', description: 'Header image URL' },
296
+ status: { type: 'string', description: 'Post status (e.g. "draft", "published")' },
297
+ },
298
+ required: ['title', 'body'],
299
+ },
300
+ },
301
+
302
+ // --- Backup ---
303
+ {
304
+ name: 'create_backup',
305
+ description: 'Create a Firestore data backup. Optionally filter with a deletion regex.',
306
+ method: 'POST',
307
+ path: 'admin/backup',
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ deletionRegex: { type: 'string', description: 'Regex pattern to filter documents for deletion (optional)' },
312
+ },
313
+ },
314
+ },
315
+
316
+ // --- Hooks ---
317
+ {
318
+ name: 'run_hook',
319
+ description: 'Execute a custom hook by path (e.g. "cron/daily/my-job")',
320
+ method: 'POST',
321
+ path: 'admin/hook',
322
+ inputSchema: {
323
+ type: 'object',
324
+ properties: {
325
+ path: { type: 'string', description: 'Hook path to execute' },
326
+ },
327
+ required: ['path'],
328
+ },
329
+ },
330
+
331
+ // --- UUID ---
332
+ {
333
+ name: 'generate_uuid',
334
+ description: 'Generate a UUID (v4 random or v5 namespace-based)',
335
+ method: 'POST',
336
+ path: 'general/uuid',
337
+ inputSchema: {
338
+ type: 'object',
339
+ properties: {
340
+ name: { type: 'string', description: 'Name for v5 UUID generation' },
341
+ input: { type: 'string', description: 'Input string for v5 UUID' },
342
+ version: { description: 'UUID version (default: "5")', default: '5' },
343
+ namespace: { type: 'string', description: 'UUID namespace' },
344
+ },
345
+ },
346
+ },
347
+
348
+ // --- Health Check ---
349
+ {
350
+ name: 'health_check',
351
+ description: 'Check if the BEM server is running and responding',
352
+ method: 'GET',
353
+ path: 'test/health',
354
+ inputSchema: {
355
+ type: 'object',
356
+ properties: {},
357
+ },
358
+ },
359
+ ];