backend-manager 5.6.4 → 5.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +4 -3
- package/PROGRESS.md +41 -0
- package/docs/ai-library.md +62 -11
- package/docs/cdp-debugging.md +44 -0
- package/docs/cli-output.md +22 -10
- package/docs/mcp.md +166 -43
- package/package.json +1 -1
- package/plans/mcp2.md +247 -0
- package/src/cli/commands/mcp.js +8 -2
- package/src/cli/commands/serve.js +155 -29
- package/src/cli/commands/setup-tests/base-test.js +8 -0
- package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
- package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
- package/src/cli/commands/setup-tests/hosting-rewrites.js +1 -1
- package/src/cli/commands/setup-tests/index.js +4 -0
- package/src/cli/commands/setup-tests/java-installed.js +26 -0
- package/src/cli/commands/setup.js +2 -1
- package/src/cli/commands/test.js +8 -0
- package/src/cli/index.js +14 -0
- package/src/cli/utils/ui.js +27 -5
- package/src/manager/index.js +9 -4
- package/src/manager/libraries/ai/index.js +45 -1
- package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
- package/src/manager/libraries/ai/providers/anthropic.js +28 -49
- package/src/manager/libraries/ai/providers/claude-code.js +21 -47
- package/src/manager/libraries/ai/providers/openai.js +154 -19
- package/src/manager/libraries/ai/providers/test.js +242 -0
- package/src/manager/libraries/email/data/disposable-domains.json +465 -0
- package/src/manager/libraries/email/generators/newsletter.js +3 -3
- package/src/mcp/client.js +48 -13
- package/src/mcp/handler.js +222 -69
- package/src/mcp/index.js +48 -18
- package/src/mcp/tools.js +150 -0
- package/src/mcp/utils.js +108 -0
- package/src/test/fixtures/firebase-project/firebase.json +1 -1
- package/test/ai/tools-live.js +170 -0
- package/test/helpers/ai-test-provider.js +202 -0
- package/test/helpers/ai-tools-format.js +350 -0
- package/test/mcp/discovery.js +53 -0
- package/test/mcp/oauth.js +161 -0
- package/test/mcp/protocol.js +268 -0
- package/test/mcp/roles.js +168 -0
- package/test/mcp/utils.js +245 -0
- package/.claude/settings.local.json +0 -12
package/src/mcp/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* BEM MCP Server
|
|
4
|
+
* BEM MCP Server (Stdio Transport)
|
|
5
5
|
*
|
|
6
6
|
* Exposes Backend Manager routes as MCP tools so Claude (or any MCP client)
|
|
7
7
|
* can interact with a running BEM instance — local or production.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
|
-
* npx bm mcp
|
|
10
|
+
* npx bm mcp # admin (uses BACKEND_MANAGER_KEY)
|
|
11
|
+
* npx bm mcp --token <api-key> # user-level (uses API key)
|
|
12
|
+
* npx bm mcp # public-only (no key, no token)
|
|
11
13
|
*
|
|
12
14
|
* Environment variables:
|
|
13
15
|
* BEM_URL - BEM server URL (default: http://localhost:5002)
|
|
@@ -17,7 +19,8 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
|
17
19
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
18
20
|
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
19
21
|
const BEMClient = require('./client.js');
|
|
20
|
-
const
|
|
22
|
+
const builtinTools = require('./tools.js');
|
|
23
|
+
const { resolveAuthInfo, filterToolsByRole, loadConsumerTools, buildToolMap } = require('./utils.js');
|
|
21
24
|
const packageJSON = require('../../package.json');
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -25,6 +28,8 @@ const packageJSON = require('../../package.json');
|
|
|
25
28
|
* @param {object} options
|
|
26
29
|
* @param {string} options.baseUrl - BEM server URL
|
|
27
30
|
* @param {string} options.backendManagerKey - Admin API key
|
|
31
|
+
* @param {string} options.userToken - User API key (for user-level connections)
|
|
32
|
+
* @param {string} options.cwd - Consumer project functions directory (for consumer tool discovery)
|
|
28
33
|
*/
|
|
29
34
|
async function startServer(options) {
|
|
30
35
|
options = options || {};
|
|
@@ -35,12 +40,30 @@ async function startServer(options) {
|
|
|
35
40
|
const backendManagerKey = options.backendManagerKey
|
|
36
41
|
|| process.env.BACKEND_MANAGER_KEY
|
|
37
42
|
|| '';
|
|
43
|
+
const userToken = options.userToken || '';
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
// Determine auth role
|
|
46
|
+
const token = backendManagerKey || userToken || '';
|
|
47
|
+
const authInfo = resolveAuthInfo(token);
|
|
48
|
+
|
|
49
|
+
if (authInfo.role === 'public') {
|
|
50
|
+
console.error('[BEM MCP] No key or token set. Only public tools will be available.');
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
// Build client with appropriate auth
|
|
54
|
+
const client = new BEMClient({
|
|
55
|
+
baseUrl,
|
|
56
|
+
backendManagerKey: authInfo.role === 'admin' ? token : '',
|
|
57
|
+
userToken: authInfo.role === 'user' ? token : '',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Load and merge consumer tools (consumer overrides win)
|
|
61
|
+
const consumerTools = loadConsumerTools(options.cwd);
|
|
62
|
+
const toolMap = buildToolMap(builtinTools, consumerTools);
|
|
63
|
+
const allTools = Array.from(toolMap.values());
|
|
64
|
+
|
|
65
|
+
// Filter by role
|
|
66
|
+
const visibleTools = filterToolsByRole(allTools, authInfo.role);
|
|
44
67
|
|
|
45
68
|
// Create the MCP server
|
|
46
69
|
const server = new Server(
|
|
@@ -55,19 +78,15 @@ async function startServer(options) {
|
|
|
55
78
|
},
|
|
56
79
|
);
|
|
57
80
|
|
|
58
|
-
//
|
|
59
|
-
const toolMap = {};
|
|
60
|
-
for (const tool of tools) {
|
|
61
|
-
toolMap[tool.name] = tool;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Handle tools/list — return all tool definitions
|
|
81
|
+
// Handle tools/list — return role-filtered tool definitions
|
|
65
82
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
66
83
|
return {
|
|
67
|
-
tools:
|
|
84
|
+
tools: visibleTools.map((tool) => ({
|
|
68
85
|
name: tool.name,
|
|
69
86
|
description: tool.description,
|
|
70
87
|
inputSchema: tool.inputSchema,
|
|
88
|
+
outputSchema: tool.outputSchema,
|
|
89
|
+
annotations: tool.annotations,
|
|
71
90
|
})),
|
|
72
91
|
};
|
|
73
92
|
});
|
|
@@ -75,19 +94,26 @@ async function startServer(options) {
|
|
|
75
94
|
// Handle tools/call — execute the requested tool
|
|
76
95
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
77
96
|
const { name, arguments: args } = request.params;
|
|
78
|
-
const tool = toolMap
|
|
97
|
+
const tool = toolMap.get(name);
|
|
79
98
|
|
|
80
|
-
if (!tool) {
|
|
99
|
+
if (!tool || !visibleTools.some((t) => t.name === name)) {
|
|
81
100
|
return {
|
|
82
101
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
83
102
|
isError: true,
|
|
84
103
|
};
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
// Handler-based consumer tools require HTTP transport
|
|
107
|
+
if (tool.handler && !tool.path) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: `Tool "${name}" requires HTTP transport (handler-based tools cannot run over stdio).` }],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
try {
|
|
88
115
|
const response = await client.call(tool.method, tool.path, args || {});
|
|
89
116
|
|
|
90
|
-
// Format the response for the LLM
|
|
91
117
|
const text = typeof response === 'string'
|
|
92
118
|
? response
|
|
93
119
|
: JSON.stringify(response, null, 2);
|
|
@@ -113,7 +139,11 @@ async function startServer(options) {
|
|
|
113
139
|
|
|
114
140
|
// Log to stderr (stdout is reserved for MCP protocol)
|
|
115
141
|
console.error(`[BEM MCP] Server running — connected to ${baseUrl}`);
|
|
116
|
-
console.error(`[BEM MCP] ${
|
|
142
|
+
console.error(`[BEM MCP] Role: ${authInfo.role} | ${visibleTools.length}/${allTools.length} tools available`);
|
|
143
|
+
|
|
144
|
+
if (consumerTools.length > 0) {
|
|
145
|
+
console.error(`[BEM MCP] ${consumerTools.length} consumer tool(s) loaded`);
|
|
146
|
+
}
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
// Allow direct execution or require
|
package/src/mcp/tools.js
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
* MCP Tool Definitions
|
|
3
3
|
*
|
|
4
4
|
* Each tool maps to a BEM route with method, path, and JSON Schema for inputs.
|
|
5
|
+
* annotations.readOnlyHint / destructiveHint control Claude Desktop's read/write categorization.
|
|
5
6
|
*/
|
|
6
7
|
module.exports = [
|
|
7
8
|
// --- Firestore ---
|
|
8
9
|
{
|
|
9
10
|
name: 'firestore_read',
|
|
10
11
|
description: 'Read a Firestore document by path (e.g. "users/abc123")',
|
|
12
|
+
role: 'admin',
|
|
11
13
|
method: 'GET',
|
|
12
14
|
path: 'admin/firestore',
|
|
15
|
+
annotations: { title: 'Read a Firestore document', readOnlyHint: true },
|
|
13
16
|
inputSchema: {
|
|
14
17
|
type: 'object',
|
|
15
18
|
properties: {
|
|
@@ -21,8 +24,10 @@ module.exports = [
|
|
|
21
24
|
{
|
|
22
25
|
name: 'firestore_write',
|
|
23
26
|
description: 'Write/merge a Firestore document. Set merge=false to overwrite entirely.',
|
|
27
|
+
role: 'admin',
|
|
24
28
|
method: 'POST',
|
|
25
29
|
path: 'admin/firestore',
|
|
30
|
+
annotations: { title: 'Write a Firestore document', readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
26
31
|
inputSchema: {
|
|
27
32
|
type: 'object',
|
|
28
33
|
properties: {
|
|
@@ -36,8 +41,10 @@ module.exports = [
|
|
|
36
41
|
{
|
|
37
42
|
name: 'firestore_query',
|
|
38
43
|
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).',
|
|
44
|
+
role: 'admin',
|
|
39
45
|
method: 'POST',
|
|
40
46
|
path: 'admin/firestore/query',
|
|
47
|
+
annotations: { title: 'Query a Firestore collection', readOnlyHint: true },
|
|
41
48
|
inputSchema: {
|
|
42
49
|
type: 'object',
|
|
43
50
|
properties: {
|
|
@@ -87,8 +94,10 @@ module.exports = [
|
|
|
87
94
|
{
|
|
88
95
|
name: 'send_email',
|
|
89
96
|
description: 'Send a transactional email via SendGrid. Recipients can be email strings, UIDs (auto-resolves from Firestore), or {email, name} objects.',
|
|
97
|
+
role: 'admin',
|
|
90
98
|
method: 'POST',
|
|
91
99
|
path: 'admin/email',
|
|
100
|
+
annotations: { title: 'Send a transactional email', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
92
101
|
inputSchema: {
|
|
93
102
|
type: 'object',
|
|
94
103
|
properties: {
|
|
@@ -111,8 +120,10 @@ module.exports = [
|
|
|
111
120
|
{
|
|
112
121
|
name: 'send_notification',
|
|
113
122
|
description: 'Send a push notification via FCM to users or topics',
|
|
123
|
+
role: 'admin',
|
|
114
124
|
method: 'POST',
|
|
115
125
|
path: 'admin/notification',
|
|
126
|
+
annotations: { title: 'Send a push notification', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
116
127
|
inputSchema: {
|
|
117
128
|
type: 'object',
|
|
118
129
|
properties: {
|
|
@@ -144,8 +155,10 @@ module.exports = [
|
|
|
144
155
|
{
|
|
145
156
|
name: 'get_user',
|
|
146
157
|
description: 'Get the currently authenticated user info. To look up a specific user, use firestore_read with path "users/{uid}" instead.',
|
|
158
|
+
role: 'user',
|
|
147
159
|
method: 'GET',
|
|
148
160
|
path: 'user',
|
|
161
|
+
annotations: { title: 'Get authenticated user info', readOnlyHint: true },
|
|
149
162
|
inputSchema: {
|
|
150
163
|
type: 'object',
|
|
151
164
|
properties: {},
|
|
@@ -154,8 +167,10 @@ module.exports = [
|
|
|
154
167
|
{
|
|
155
168
|
name: 'get_subscription',
|
|
156
169
|
description: 'Get subscription info for a user. Defaults to the authenticated user, or pass a uid to look up another user (admin only).',
|
|
170
|
+
role: 'user',
|
|
157
171
|
method: 'GET',
|
|
158
172
|
path: 'user/subscription',
|
|
173
|
+
annotations: { title: 'Get subscription info', readOnlyHint: true },
|
|
159
174
|
inputSchema: {
|
|
160
175
|
type: 'object',
|
|
161
176
|
properties: {
|
|
@@ -166,8 +181,10 @@ module.exports = [
|
|
|
166
181
|
{
|
|
167
182
|
name: 'sync_users',
|
|
168
183
|
description: 'Sync user data across systems (marketing contacts, etc). Processes users in batches.',
|
|
184
|
+
role: 'admin',
|
|
169
185
|
method: 'POST',
|
|
170
186
|
path: 'admin/users/sync',
|
|
187
|
+
annotations: { title: 'Sync users across systems', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
171
188
|
inputSchema: {
|
|
172
189
|
type: 'object',
|
|
173
190
|
properties: {},
|
|
@@ -178,8 +195,10 @@ module.exports = [
|
|
|
178
195
|
{
|
|
179
196
|
name: 'list_campaigns',
|
|
180
197
|
description: 'List marketing campaigns with optional filters by date range, status, and type',
|
|
198
|
+
role: 'admin',
|
|
181
199
|
method: 'GET',
|
|
182
200
|
path: 'marketing/campaign',
|
|
201
|
+
annotations: { title: 'List marketing campaigns', readOnlyHint: true },
|
|
183
202
|
inputSchema: {
|
|
184
203
|
type: 'object',
|
|
185
204
|
properties: {
|
|
@@ -195,8 +214,10 @@ module.exports = [
|
|
|
195
214
|
{
|
|
196
215
|
name: 'create_campaign',
|
|
197
216
|
description: 'Create a marketing campaign (email or push notification). Can be immediate or scheduled.',
|
|
217
|
+
role: 'admin',
|
|
198
218
|
method: 'POST',
|
|
199
219
|
path: 'marketing/campaign',
|
|
220
|
+
annotations: { title: 'Create a marketing campaign', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
200
221
|
inputSchema: {
|
|
201
222
|
type: 'object',
|
|
202
223
|
properties: {
|
|
@@ -217,12 +238,92 @@ module.exports = [
|
|
|
217
238
|
},
|
|
218
239
|
},
|
|
219
240
|
|
|
241
|
+
{
|
|
242
|
+
name: 'update_campaign',
|
|
243
|
+
description: 'Update a pending marketing campaign. Only pending campaigns can be edited.',
|
|
244
|
+
role: 'admin',
|
|
245
|
+
method: 'PUT',
|
|
246
|
+
path: 'marketing/campaign',
|
|
247
|
+
annotations: { title: 'Update a campaign', readOnlyHint: false, destructiveHint: false },
|
|
248
|
+
inputSchema: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
properties: {
|
|
251
|
+
id: { type: 'string', description: 'Campaign ID to update' },
|
|
252
|
+
name: { type: 'string', description: 'Campaign name' },
|
|
253
|
+
subject: { type: 'string', description: 'Email subject line' },
|
|
254
|
+
preheader: { type: 'string', description: 'Email preheader text' },
|
|
255
|
+
template: { type: 'string', description: 'Email template name' },
|
|
256
|
+
data: { type: 'object', description: 'Template data' },
|
|
257
|
+
segments: { type: 'array', items: { type: 'string' }, description: 'Target segment keys' },
|
|
258
|
+
excludeSegments: { type: 'array', items: { type: 'string' }, description: 'Exclude segment keys' },
|
|
259
|
+
all: { type: 'boolean', description: 'Send to all contacts' },
|
|
260
|
+
sendAt: { description: 'Reschedule time (ISO string or unix timestamp)' },
|
|
261
|
+
sender: { type: 'string', description: 'Sender preset name' },
|
|
262
|
+
},
|
|
263
|
+
required: ['id'],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'delete_campaign',
|
|
268
|
+
description: 'Delete a pending marketing campaign. Only pending campaigns can be deleted.',
|
|
269
|
+
role: 'admin',
|
|
270
|
+
method: 'DELETE',
|
|
271
|
+
path: 'marketing/campaign',
|
|
272
|
+
annotations: { title: 'Delete a campaign', readOnlyHint: false, destructiveHint: true },
|
|
273
|
+
inputSchema: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
id: { type: 'string', description: 'Campaign ID to delete' },
|
|
277
|
+
},
|
|
278
|
+
required: ['id'],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// --- Marketing Contacts ---
|
|
283
|
+
{
|
|
284
|
+
name: 'create_contact',
|
|
285
|
+
description: 'Add a marketing contact to email providers (SendGrid/Beehiiv). Admin mode skips reCAPTCHA and allows tags.',
|
|
286
|
+
role: 'admin',
|
|
287
|
+
method: 'POST',
|
|
288
|
+
path: 'marketing/contact',
|
|
289
|
+
annotations: { title: 'Add a marketing contact', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
290
|
+
inputSchema: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
email: { type: 'string', description: 'Contact email address' },
|
|
294
|
+
firstName: { type: 'string', description: 'First name' },
|
|
295
|
+
lastName: { type: 'string', description: 'Last name' },
|
|
296
|
+
source: { type: 'string', description: 'Contact source (e.g. "manual", "import")' },
|
|
297
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Contact tags' },
|
|
298
|
+
skipValidation: { type: 'boolean', description: 'Skip email validation (admin only)', default: false },
|
|
299
|
+
},
|
|
300
|
+
required: ['email'],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'delete_contact',
|
|
305
|
+
description: 'Remove a marketing contact from email providers and revoke marketing consent.',
|
|
306
|
+
role: 'admin',
|
|
307
|
+
method: 'DELETE',
|
|
308
|
+
path: 'marketing/contact',
|
|
309
|
+
annotations: { title: 'Remove a marketing contact', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
310
|
+
inputSchema: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
email: { type: 'string', description: 'Contact email to remove' },
|
|
314
|
+
},
|
|
315
|
+
required: ['email'],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
|
|
220
319
|
// --- Stats ---
|
|
221
320
|
{
|
|
222
321
|
name: 'get_stats',
|
|
223
322
|
description: 'Get system statistics (user counts, subscription metrics, etc.)',
|
|
323
|
+
role: 'admin',
|
|
224
324
|
method: 'GET',
|
|
225
325
|
path: 'admin/stats',
|
|
326
|
+
annotations: { title: 'Get system statistics', readOnlyHint: true },
|
|
226
327
|
inputSchema: {
|
|
227
328
|
type: 'object',
|
|
228
329
|
properties: {
|
|
@@ -235,8 +336,10 @@ module.exports = [
|
|
|
235
336
|
{
|
|
236
337
|
name: 'cancel_subscription',
|
|
237
338
|
description: 'Cancel a subscription at the end of the current billing period. Requires the authenticated user to have an active subscription.',
|
|
339
|
+
role: 'admin',
|
|
238
340
|
method: 'POST',
|
|
239
341
|
path: 'payments/cancel',
|
|
342
|
+
annotations: { title: 'Cancel a subscription', readOnlyHint: false, destructiveHint: true },
|
|
240
343
|
inputSchema: {
|
|
241
344
|
type: 'object',
|
|
242
345
|
properties: {
|
|
@@ -250,8 +353,10 @@ module.exports = [
|
|
|
250
353
|
{
|
|
251
354
|
name: 'refund_payment',
|
|
252
355
|
description: 'Process a refund for a subscription. Immediately cancels and refunds the latest payment.',
|
|
356
|
+
role: 'admin',
|
|
253
357
|
method: 'POST',
|
|
254
358
|
path: 'payments/refund',
|
|
359
|
+
annotations: { title: 'Refund a payment', readOnlyHint: false, destructiveHint: true },
|
|
255
360
|
inputSchema: {
|
|
256
361
|
type: 'object',
|
|
257
362
|
properties: {
|
|
@@ -263,12 +368,29 @@ module.exports = [
|
|
|
263
368
|
},
|
|
264
369
|
},
|
|
265
370
|
|
|
371
|
+
{
|
|
372
|
+
name: 'get_payment_portal',
|
|
373
|
+
description: 'Generate a Stripe Billing Portal link for the authenticated user to manage their subscription.',
|
|
374
|
+
role: 'admin',
|
|
375
|
+
method: 'POST',
|
|
376
|
+
path: 'payments/portal',
|
|
377
|
+
annotations: { title: 'Get payment portal link', readOnlyHint: true, openWorldHint: true },
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
properties: {
|
|
381
|
+
returnUrl: { type: 'string', description: 'URL to redirect to after the portal session' },
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
|
|
266
386
|
// --- Cron ---
|
|
267
387
|
{
|
|
268
388
|
name: 'run_cron',
|
|
269
389
|
description: 'Manually trigger a cron job by ID (e.g. "daily", "reset-usage", "marketing-campaigns")',
|
|
390
|
+
role: 'admin',
|
|
270
391
|
method: 'POST',
|
|
271
392
|
path: 'admin/cron',
|
|
393
|
+
annotations: { title: 'Trigger a cron job', readOnlyHint: false, destructiveHint: false },
|
|
272
394
|
inputSchema: {
|
|
273
395
|
type: 'object',
|
|
274
396
|
properties: {
|
|
@@ -282,8 +404,10 @@ module.exports = [
|
|
|
282
404
|
{
|
|
283
405
|
name: 'create_post',
|
|
284
406
|
description: 'Create a blog post. Handles image downloading, GitHub upload, and body rewriting.',
|
|
407
|
+
role: 'admin',
|
|
285
408
|
method: 'POST',
|
|
286
409
|
path: 'admin/post',
|
|
410
|
+
annotations: { title: 'Create a blog post', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
287
411
|
inputSchema: {
|
|
288
412
|
type: 'object',
|
|
289
413
|
properties: {
|
|
@@ -297,13 +421,33 @@ module.exports = [
|
|
|
297
421
|
required: ['title', 'body'],
|
|
298
422
|
},
|
|
299
423
|
},
|
|
424
|
+
{
|
|
425
|
+
name: 'update_post',
|
|
426
|
+
description: 'Update an existing blog post. Fetches the post by URL and uploads changes via GitHub.',
|
|
427
|
+
role: 'admin',
|
|
428
|
+
method: 'PUT',
|
|
429
|
+
path: 'admin/post',
|
|
430
|
+
annotations: { title: 'Update a blog post', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
431
|
+
inputSchema: {
|
|
432
|
+
type: 'object',
|
|
433
|
+
properties: {
|
|
434
|
+
url: { type: 'string', description: 'Blog post URL to update' },
|
|
435
|
+
body: { type: 'string', description: 'Updated post content body' },
|
|
436
|
+
title: { type: 'string', description: 'Updated post title' },
|
|
437
|
+
postPath: { type: 'string', description: 'Path to the post (default: "guest")' },
|
|
438
|
+
},
|
|
439
|
+
required: ['url', 'body'],
|
|
440
|
+
},
|
|
441
|
+
},
|
|
300
442
|
|
|
301
443
|
// --- Backup ---
|
|
302
444
|
{
|
|
303
445
|
name: 'create_backup',
|
|
304
446
|
description: 'Create a Firestore data backup. Optionally filter with a deletion regex.',
|
|
447
|
+
role: 'admin',
|
|
305
448
|
method: 'POST',
|
|
306
449
|
path: 'admin/backup',
|
|
450
|
+
annotations: { title: 'Create a Firestore backup', readOnlyHint: false, destructiveHint: false },
|
|
307
451
|
inputSchema: {
|
|
308
452
|
type: 'object',
|
|
309
453
|
properties: {
|
|
@@ -316,8 +460,10 @@ module.exports = [
|
|
|
316
460
|
{
|
|
317
461
|
name: 'run_hook',
|
|
318
462
|
description: 'Execute a custom hook by path (e.g. "cron/daily/my-job")',
|
|
463
|
+
role: 'admin',
|
|
319
464
|
method: 'POST',
|
|
320
465
|
path: 'admin/hook',
|
|
466
|
+
annotations: { title: 'Run a custom hook', readOnlyHint: false, destructiveHint: false },
|
|
321
467
|
inputSchema: {
|
|
322
468
|
type: 'object',
|
|
323
469
|
properties: {
|
|
@@ -331,8 +477,10 @@ module.exports = [
|
|
|
331
477
|
{
|
|
332
478
|
name: 'generate_uuid',
|
|
333
479
|
description: 'Generate a UUID (v4 random or v5 namespace-based)',
|
|
480
|
+
role: 'admin',
|
|
334
481
|
method: 'POST',
|
|
335
482
|
path: 'general/uuid',
|
|
483
|
+
annotations: { title: 'Generate a UUID', readOnlyHint: true },
|
|
336
484
|
inputSchema: {
|
|
337
485
|
type: 'object',
|
|
338
486
|
properties: {
|
|
@@ -348,8 +496,10 @@ module.exports = [
|
|
|
348
496
|
{
|
|
349
497
|
name: 'health_check',
|
|
350
498
|
description: 'Check if the BEM server is running and responding',
|
|
499
|
+
role: 'public',
|
|
351
500
|
method: 'GET',
|
|
352
501
|
path: 'test/health',
|
|
502
|
+
annotations: { title: 'Check server health', readOnlyHint: true },
|
|
353
503
|
inputSchema: {
|
|
354
504
|
type: 'object',
|
|
355
505
|
properties: {},
|
package/src/mcp/utils.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const ROLE_HIERARCHY = {
|
|
4
|
+
admin: ['admin', 'user', 'public'],
|
|
5
|
+
user: ['user', 'public'],
|
|
6
|
+
public: ['public'],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Classify a Bearer token into a role without hitting the database.
|
|
11
|
+
* Actual validation happens at the route level when a tool is called.
|
|
12
|
+
*/
|
|
13
|
+
function resolveAuthInfo(token) {
|
|
14
|
+
const configKey = process.env.BACKEND_MANAGER_KEY || '';
|
|
15
|
+
|
|
16
|
+
if (token && configKey && token === configKey) {
|
|
17
|
+
return { role: 'admin', authType: 'adminKey', token };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (token) {
|
|
21
|
+
return { role: 'user', authType: 'userToken', token };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { role: 'public', authType: 'none', token: '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filter tools to only those visible for a given role.
|
|
29
|
+
* admin → all, user → user + public, public → public only.
|
|
30
|
+
*/
|
|
31
|
+
function filterToolsByRole(tools, role) {
|
|
32
|
+
const allowed = ROLE_HIERARCHY[role] || ROLE_HIERARCHY.public;
|
|
33
|
+
|
|
34
|
+
return tools.filter((tool) => allowed.includes(tool.role || 'admin'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load consumer MCP tools from `functions/mcp.js` if it exists.
|
|
39
|
+
* Returns an empty array if the file doesn't exist or fails to load.
|
|
40
|
+
*/
|
|
41
|
+
function loadConsumerTools(cwd) {
|
|
42
|
+
if (!cwd) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mcpPath = path.join(cwd, 'mcp.js');
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const jetpack = require('fs-jetpack');
|
|
50
|
+
|
|
51
|
+
if (!jetpack.exists(mcpPath)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const consumerTools = require(mcpPath);
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(consumerTools)) {
|
|
58
|
+
console.error(`[BEM MCP] Consumer mcp.js must export an array, got ${typeof consumerTools}`);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const tool of consumerTools) {
|
|
63
|
+
if (!tool.name || !tool.description) {
|
|
64
|
+
console.error(`[BEM MCP] Consumer tool missing name or description:`, tool);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!tool.path && !tool.handler) {
|
|
69
|
+
console.error(`[BEM MCP] Consumer tool "${tool.name}" must have a path or handler`);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
tool.role = tool.role || 'admin';
|
|
74
|
+
tool._consumer = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return consumerTools;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(`[BEM MCP] Failed to load consumer tools from ${mcpPath}:`, error.message);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merge built-in and consumer tools into a Map.
|
|
86
|
+
* Consumer tools with the same name override built-ins.
|
|
87
|
+
*/
|
|
88
|
+
function buildToolMap(builtinTools, consumerTools) {
|
|
89
|
+
const map = new Map();
|
|
90
|
+
|
|
91
|
+
for (const tool of builtinTools) {
|
|
92
|
+
map.set(tool.name, tool);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const tool of consumerTools) {
|
|
96
|
+
map.set(tool.name, tool);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return map;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
resolveAuthInfo,
|
|
104
|
+
filterToolsByRole,
|
|
105
|
+
loadConsumerTools,
|
|
106
|
+
buildToolMap,
|
|
107
|
+
ROLE_HIERARCHY,
|
|
108
|
+
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
],
|
|
9
9
|
"rewrites": [
|
|
10
10
|
{
|
|
11
|
-
"source": "{/backend-manager,/backend-manager
|
|
11
|
+
"source": "{/backend-manager,/backend-manager/**,/mcp,/mcp/**,/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/authorize,/token,/register}",
|
|
12
12
|
"function": "bm_api"
|
|
13
13
|
}
|
|
14
14
|
]
|