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.
- package/TODO-CHARGEBLAST.md +32 -0
- package/package.json +2 -1
- package/src/cli/commands/mcp.js +32 -0
- package/src/cli/index.js +7 -0
- package/src/manager/events/auth/before-create.js +1 -1
- package/src/manager/index.js +21 -0
- package/src/manager/libraries/disposable-domains.json +20 -3
- package/src/mcp/client.js +64 -0
- package/src/mcp/handler.js +342 -0
- package/src/mcp/index.js +127 -0
- package/src/mcp/tools.js +359 -0
|
@@ -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.
|
|
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
|
package/src/manager/index.js
CHANGED
|
@@ -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, '&')
|
|
337
|
+
.replace(/</g, '<')
|
|
338
|
+
.replace(/>/g, '>')
|
|
339
|
+
.replace(/"/g, '"');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = { handleMcpRoute };
|
package/src/mcp/index.js
ADDED
|
@@ -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 };
|
package/src/mcp/tools.js
ADDED
|
@@ -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
|
+
];
|