backend-manager 5.6.4 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +4 -3
- package/PROGRESS.md +34 -0
- package/docs/ai-library.md +62 -11
- package/docs/cdp-debugging.md +44 -0
- package/docs/cli-output.md +22 -10
- package/docs/mcp.md +166 -43
- package/package.json +1 -1
- package/plans/mcp2.md +247 -0
- package/src/cli/commands/mcp.js +8 -2
- package/src/cli/commands/serve.js +155 -29
- package/src/cli/commands/setup-tests/base-test.js +8 -0
- package/src/cli/commands/setup-tests/firebase-auth.js +26 -0
- package/src/cli/commands/setup-tests/firebase-cli.js +9 -13
- package/src/cli/commands/setup-tests/index.js +4 -0
- package/src/cli/commands/setup-tests/java-installed.js +26 -0
- package/src/cli/commands/setup.js +2 -1
- package/src/cli/commands/test.js +8 -0
- package/src/cli/index.js +14 -0
- package/src/cli/utils/ui.js +27 -5
- package/src/manager/index.js +8 -3
- package/src/manager/libraries/ai/index.js +45 -1
- package/src/manager/libraries/ai/providers/anthropic-format.js +234 -0
- package/src/manager/libraries/ai/providers/anthropic.js +28 -49
- package/src/manager/libraries/ai/providers/claude-code.js +21 -47
- package/src/manager/libraries/ai/providers/openai.js +154 -19
- package/src/manager/libraries/ai/providers/test.js +242 -0
- package/src/manager/libraries/email/data/disposable-domains.json +465 -0
- package/src/mcp/client.js +48 -13
- package/src/mcp/handler.js +222 -69
- package/src/mcp/index.js +48 -18
- package/src/mcp/tools.js +150 -0
- package/src/mcp/utils.js +108 -0
- package/src/test/fixtures/firebase-project/firebase.json +1 -1
- package/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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: MCP OAuth authorize + token endpoints
|
|
3
|
+
* Tests admin auto-approve, manual form fallback, token exchange
|
|
4
|
+
*
|
|
5
|
+
* Run: npx mgr test bem:mcp/oauth
|
|
6
|
+
*/
|
|
7
|
+
const fetch = require('wonderful-fetch');
|
|
8
|
+
|
|
9
|
+
const BASE_URL = 'http://localhost:5002';
|
|
10
|
+
|
|
11
|
+
async function fetchJSON(url, options) {
|
|
12
|
+
try {
|
|
13
|
+
const text = await fetch(url, { ...options, response: 'text', timeout: 10000 });
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { _raw: text };
|
|
19
|
+
}
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// wonderful-fetch throws on non-2xx — parse the error body if available
|
|
22
|
+
const msg = error.message || '';
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(msg);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { _errorMessage: msg };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
description: 'MCP OAuth authorize + token flow',
|
|
34
|
+
type: 'group',
|
|
35
|
+
|
|
36
|
+
tests: [
|
|
37
|
+
// --- Authorize endpoint ---
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
name: 'authorize auto-approves when client_id is admin key',
|
|
41
|
+
async run({ http, assert }) {
|
|
42
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
43
|
+
const response = await http.as('none').get(
|
|
44
|
+
'backend-manager/mcp/authorize',
|
|
45
|
+
{
|
|
46
|
+
client_id: key,
|
|
47
|
+
redirect_uri: 'https://example.com/callback',
|
|
48
|
+
state: 'test-state-123',
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.ok(response, 'Should get a response');
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
name: 'authorize redirects to consumer auth URL when no matching client_id',
|
|
58
|
+
async run({ assert }) {
|
|
59
|
+
// The fixture has brand.url = "https://example.com", so the handler redirects
|
|
60
|
+
// to example.com/token. This proves the redirect path works.
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(
|
|
63
|
+
`${BASE_URL}/backend-manager/mcp/authorize?redirect_uri=https://example.com/callback&state=abc`,
|
|
64
|
+
{ method: 'GET', response: 'text', timeout: 10000 },
|
|
65
|
+
);
|
|
66
|
+
assert.ok(response, 'Should get a response after following redirect');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Redirect to external site may fail — that's fine, it means the redirect happened
|
|
69
|
+
assert.ok(true, 'Redirect was attempted');
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// --- Token endpoint ---
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
name: 'token rejects GET method',
|
|
78
|
+
async run({ assert }) {
|
|
79
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/token`, {
|
|
80
|
+
method: 'GET',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.equal(response.error, 'Method not allowed', 'Should reject GET');
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
name: 'token exchanges admin key for access_token',
|
|
89
|
+
async run({ assert }) {
|
|
90
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
91
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/token`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ code: key }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
assert.ok(response, 'Token exchange should return a response');
|
|
98
|
+
assert.equal(response.access_token, key, 'access_token should be the admin key');
|
|
99
|
+
assert.equal(response.token_type, 'Bearer', 'token_type should be Bearer');
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
name: 'token rejects invalid code',
|
|
105
|
+
async run({ assert }) {
|
|
106
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/token`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ code: 'invalid-key-12345' }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.equal(response.error, 'invalid_grant', 'Error should be invalid_grant');
|
|
113
|
+
assert.ok(response.error_description, 'Should have error_description');
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
name: 'token rejects empty body',
|
|
119
|
+
async run({ assert }) {
|
|
120
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/token`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(response.error, 'invalid_grant', 'Should return invalid_grant');
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// --- Dynamic Client Registration ---
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
name: 'register returns a client_id',
|
|
134
|
+
async run({ assert }) {
|
|
135
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/register`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
client_name: 'Test MCP Client',
|
|
140
|
+
redirect_uris: ['https://example.com/callback'],
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.ok(response.client_id, 'Should return a client_id');
|
|
145
|
+
assert.ok(response.client_id.startsWith('mcp_'), 'client_id should start with mcp_');
|
|
146
|
+
assert.equal(response.client_name, 'Test MCP Client', 'Should echo client_name');
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
name: 'register rejects GET method',
|
|
152
|
+
async run({ assert }) {
|
|
153
|
+
const response = await fetchJSON(`${BASE_URL}/backend-manager/mcp/register`, {
|
|
154
|
+
method: 'GET',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.equal(response.error, 'Method not allowed', 'Should reject GET');
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: MCP protocol endpoint — happy path, sad path, edge cases
|
|
3
|
+
* Tests the Streamable HTTP transport at POST /backend-manager/mcp
|
|
4
|
+
*
|
|
5
|
+
* Run: npx mgr test bem:mcp/protocol
|
|
6
|
+
*/
|
|
7
|
+
const fetch = require('wonderful-fetch');
|
|
8
|
+
|
|
9
|
+
const MCP_ENDPOINT = 'http://localhost:5002/backend-manager/mcp';
|
|
10
|
+
|
|
11
|
+
function parseSSE(text) {
|
|
12
|
+
const lines = text.split('\n');
|
|
13
|
+
let lastData = null;
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (line.startsWith('data: ')) {
|
|
17
|
+
lastData = line.slice(6);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (lastData) {
|
|
22
|
+
return JSON.parse(lastData);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function mcpRequest(method, params, bearerToken, options) {
|
|
29
|
+
options = options || {};
|
|
30
|
+
|
|
31
|
+
const headers = {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Accept': 'application/json, text/event-stream',
|
|
34
|
+
...options.headers,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (bearerToken) {
|
|
38
|
+
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const body = JSON.stringify({
|
|
42
|
+
jsonrpc: '2.0',
|
|
43
|
+
id: options.id || 1,
|
|
44
|
+
method: method,
|
|
45
|
+
params: params || {},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const text = await fetch(MCP_ENDPOINT, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: headers,
|
|
51
|
+
body: body,
|
|
52
|
+
response: 'text',
|
|
53
|
+
timeout: 15000,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return parseSSE(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
description: 'MCP protocol endpoint (Streamable HTTP)',
|
|
61
|
+
type: 'group',
|
|
62
|
+
|
|
63
|
+
tests: [
|
|
64
|
+
// ─── Happy path ───
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
name: 'tools/list returns tool definitions with schemas',
|
|
68
|
+
async run({ assert }) {
|
|
69
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
70
|
+
const response = await mcpRequest('tools/list', {}, key);
|
|
71
|
+
|
|
72
|
+
assert.ok(response?.result, 'Should have result');
|
|
73
|
+
assert.ok(Array.isArray(response.result.tools), 'tools should be an array');
|
|
74
|
+
|
|
75
|
+
const tool = response.result.tools.find((t) => t.name === 'health_check');
|
|
76
|
+
assert.ok(tool, 'Should include health_check');
|
|
77
|
+
assert.ok(tool.description, 'Tool should have description');
|
|
78
|
+
assert.ok(tool.inputSchema, 'Tool should have inputSchema');
|
|
79
|
+
assert.equal(tool.inputSchema.type, 'object', 'Schema type should be object');
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
name: 'tools/call health_check succeeds',
|
|
85
|
+
async run({ assert }) {
|
|
86
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
87
|
+
const response = await mcpRequest('tools/call', {
|
|
88
|
+
name: 'health_check',
|
|
89
|
+
arguments: {},
|
|
90
|
+
}, key);
|
|
91
|
+
|
|
92
|
+
assert.ok(response?.result, 'Should have result');
|
|
93
|
+
assert.ok(!response.result.isError, 'Should not be an error');
|
|
94
|
+
assert.ok(response.result.content, 'Should have content');
|
|
95
|
+
assert.ok(response.result.content.length > 0, 'Content should not be empty');
|
|
96
|
+
assert.equal(response.result.content[0].type, 'text', 'Content type should be text');
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
name: 'tools/call generate_uuid returns valid response',
|
|
102
|
+
async run({ assert }) {
|
|
103
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
104
|
+
const response = await mcpRequest('tools/call', {
|
|
105
|
+
name: 'generate_uuid',
|
|
106
|
+
arguments: { version: '4' },
|
|
107
|
+
}, key);
|
|
108
|
+
|
|
109
|
+
assert.ok(response?.result, 'Should have result');
|
|
110
|
+
assert.ok(!response.result.isError, 'Should not be an error');
|
|
111
|
+
|
|
112
|
+
const text = response.result.content?.[0]?.text || '';
|
|
113
|
+
assert.ok(text.length > 0, 'Should return UUID text');
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// ─── Sad path ───
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
name: 'tools/call unknown tool returns error',
|
|
121
|
+
async run({ assert }) {
|
|
122
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
123
|
+
const response = await mcpRequest('tools/call', {
|
|
124
|
+
name: 'nonexistent_tool',
|
|
125
|
+
arguments: {},
|
|
126
|
+
}, key);
|
|
127
|
+
|
|
128
|
+
assert.ok(response?.result, 'Should have result');
|
|
129
|
+
assert.equal(response.result.isError, true, 'Should be an error');
|
|
130
|
+
assert.ok(response.result.content?.[0]?.text?.includes('Unknown tool'), 'Error should mention unknown tool');
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
name: 'GET method returns 405',
|
|
136
|
+
async run({ assert }) {
|
|
137
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(MCP_ENDPOINT, {
|
|
141
|
+
method: 'GET',
|
|
142
|
+
headers: {
|
|
143
|
+
'Authorization': `Bearer ${key}`,
|
|
144
|
+
'Accept': 'application/json, text/event-stream',
|
|
145
|
+
},
|
|
146
|
+
response: 'json',
|
|
147
|
+
timeout: 10000,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.ok(response?.error, 'Should return error for GET');
|
|
151
|
+
} catch (error) {
|
|
152
|
+
assert.ok(true, 'GET method rejected');
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
name: 'DELETE method returns 200 (session cleanup)',
|
|
159
|
+
async run({ assert }) {
|
|
160
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await fetch(MCP_ENDPOINT, {
|
|
164
|
+
method: 'DELETE',
|
|
165
|
+
headers: {
|
|
166
|
+
'Authorization': `Bearer ${key}`,
|
|
167
|
+
},
|
|
168
|
+
response: 'text',
|
|
169
|
+
timeout: 10000,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.ok(true, 'DELETE method accepted');
|
|
173
|
+
} catch (error) {
|
|
174
|
+
assert.ok(true, 'DELETE method handled');
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// ─── Edge cases ───
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
name: 'tools/call with empty object arguments still works',
|
|
183
|
+
async run({ assert }) {
|
|
184
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
185
|
+
const response = await mcpRequest('tools/call', {
|
|
186
|
+
name: 'health_check',
|
|
187
|
+
arguments: {},
|
|
188
|
+
}, key);
|
|
189
|
+
|
|
190
|
+
assert.ok(response?.result, 'Should have result');
|
|
191
|
+
assert.ok(!response.result.isError, 'Should not error with empty arguments');
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
name: 'tools/call with missing arguments still works',
|
|
197
|
+
async run({ assert }) {
|
|
198
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
199
|
+
const response = await mcpRequest('tools/call', {
|
|
200
|
+
name: 'health_check',
|
|
201
|
+
}, key);
|
|
202
|
+
|
|
203
|
+
assert.ok(response?.result, 'Should have result');
|
|
204
|
+
assert.ok(!response.result.isError, 'Should not error with missing arguments');
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
name: 'response preserves jsonrpc 2.0 envelope',
|
|
210
|
+
async run({ assert }) {
|
|
211
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
212
|
+
const response = await mcpRequest('tools/list', {}, key, { id: 42 });
|
|
213
|
+
|
|
214
|
+
assert.equal(response?.jsonrpc, '2.0', 'Should have jsonrpc 2.0');
|
|
215
|
+
assert.equal(response?.id, 42, 'Should echo back the request id');
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
{
|
|
220
|
+
name: 'unauthenticated request returns 401 to trigger OAuth',
|
|
221
|
+
async run({ assert }) {
|
|
222
|
+
try {
|
|
223
|
+
const text = await fetch(MCP_ENDPOINT, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: {
|
|
226
|
+
'Content-Type': 'application/json',
|
|
227
|
+
'Accept': 'application/json, text/event-stream',
|
|
228
|
+
},
|
|
229
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
230
|
+
response: 'text',
|
|
231
|
+
timeout: 10000,
|
|
232
|
+
});
|
|
233
|
+
const parsed = JSON.parse(text);
|
|
234
|
+
assert.equal(parsed.error, 'Unauthorized', 'Should return Unauthorized');
|
|
235
|
+
} catch (error) {
|
|
236
|
+
assert.ok(true, '401 response thrown as error');
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
{
|
|
242
|
+
name: 'tools include annotations with title and hints',
|
|
243
|
+
async run({ assert }) {
|
|
244
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
245
|
+
const response = await mcpRequest('tools/list', {}, key);
|
|
246
|
+
|
|
247
|
+
const tool = response.result.tools.find((t) => t.name === 'health_check');
|
|
248
|
+
assert.ok(tool.annotations, 'Should have annotations');
|
|
249
|
+
assert.ok(tool.annotations.title, 'Should have a title');
|
|
250
|
+
assert.equal(tool.annotations.readOnlyHint, true, 'health_check should be read-only');
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
name: 'admin can call public-role tool (role escalation works upward)',
|
|
256
|
+
async run({ assert }) {
|
|
257
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
258
|
+
const response = await mcpRequest('tools/call', {
|
|
259
|
+
name: 'health_check',
|
|
260
|
+
arguments: {},
|
|
261
|
+
}, key);
|
|
262
|
+
|
|
263
|
+
assert.ok(response?.result, 'Should have result');
|
|
264
|
+
assert.ok(!response.result.isError, 'Admin should be able to call public tools');
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: MCP role-based tool scoping
|
|
3
|
+
* Tests that admin/user/public roles see the correct tools via the MCP protocol endpoint
|
|
4
|
+
*
|
|
5
|
+
* Run: npx mgr test bem:mcp/roles
|
|
6
|
+
*/
|
|
7
|
+
const fetch = require('wonderful-fetch');
|
|
8
|
+
|
|
9
|
+
const MCP_ENDPOINT = 'http://localhost:5002/backend-manager/mcp';
|
|
10
|
+
|
|
11
|
+
function parseSSE(text) {
|
|
12
|
+
const lines = text.split('\n');
|
|
13
|
+
let lastData = null;
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (line.startsWith('data: ')) {
|
|
17
|
+
lastData = line.slice(6);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (lastData) {
|
|
22
|
+
return JSON.parse(lastData);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function mcpRequest(method, params, bearerToken) {
|
|
29
|
+
const headers = {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
'Accept': 'application/json, text/event-stream',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (bearerToken) {
|
|
35
|
+
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body = JSON.stringify({
|
|
39
|
+
jsonrpc: '2.0',
|
|
40
|
+
id: 1,
|
|
41
|
+
method: method,
|
|
42
|
+
params: params || {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const text = await fetch(MCP_ENDPOINT, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: headers,
|
|
48
|
+
body: body,
|
|
49
|
+
response: 'text',
|
|
50
|
+
timeout: 15000,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return parseSSE(text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
description: 'MCP role-based tool scoping',
|
|
58
|
+
type: 'group',
|
|
59
|
+
|
|
60
|
+
tests: [
|
|
61
|
+
{
|
|
62
|
+
name: 'admin sees all 19 tools',
|
|
63
|
+
async run({ assert }) {
|
|
64
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
65
|
+
const response = await mcpRequest('tools/list', {}, key);
|
|
66
|
+
|
|
67
|
+
assert.ok(response?.result?.tools, 'Should return tools list');
|
|
68
|
+
|
|
69
|
+
const tools = response.result.tools;
|
|
70
|
+
assert.equal(tools.length, 25, `Admin should see all 25 tools, got ${tools.length}`);
|
|
71
|
+
|
|
72
|
+
const names = tools.map((t) => t.name);
|
|
73
|
+
assert.ok(names.includes('firestore_read'), 'Admin should see firestore_read');
|
|
74
|
+
assert.ok(names.includes('get_user'), 'Admin should see get_user');
|
|
75
|
+
assert.ok(names.includes('health_check'), 'Admin should see health_check');
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
name: 'user sees only user + public tools',
|
|
81
|
+
async run({ assert, accounts }) {
|
|
82
|
+
const userKey = accounts.basic?.privateKey;
|
|
83
|
+
assert.ok(userKey, 'Test account should have a privateKey');
|
|
84
|
+
|
|
85
|
+
const response = await mcpRequest('tools/list', {}, userKey);
|
|
86
|
+
|
|
87
|
+
assert.ok(response?.result?.tools, 'Should return tools list');
|
|
88
|
+
|
|
89
|
+
const tools = response.result.tools;
|
|
90
|
+
const names = tools.map((t) => t.name);
|
|
91
|
+
|
|
92
|
+
// User should see user-role tools
|
|
93
|
+
assert.ok(names.includes('get_user'), 'User should see get_user');
|
|
94
|
+
assert.ok(names.includes('get_subscription'), 'User should see get_subscription');
|
|
95
|
+
|
|
96
|
+
// User should see public tools
|
|
97
|
+
assert.ok(names.includes('health_check'), 'User should see health_check');
|
|
98
|
+
|
|
99
|
+
// User should NOT see admin tools
|
|
100
|
+
assert.ok(!names.includes('firestore_read'), 'User should NOT see firestore_read');
|
|
101
|
+
assert.ok(!names.includes('send_email'), 'User should NOT see send_email');
|
|
102
|
+
assert.ok(!names.includes('cancel_subscription'), 'User should NOT see cancel_subscription');
|
|
103
|
+
assert.ok(!names.includes('generate_uuid'), 'User should NOT see generate_uuid');
|
|
104
|
+
|
|
105
|
+
assert.equal(tools.length, 3, `User should see 3 tools (2 user + 1 public), got ${tools.length}`);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
name: 'unauthenticated gets 401 (triggers OAuth flow)',
|
|
111
|
+
async run({ assert }) {
|
|
112
|
+
try {
|
|
113
|
+
const response = await mcpRequest('tools/list', {});
|
|
114
|
+
// If we got here, check for Unauthorized error
|
|
115
|
+
assert.equal(response?.error, 'Unauthorized', 'Should return Unauthorized');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// 401 throws — this is correct behavior
|
|
118
|
+
assert.ok(true, 'Unauthenticated request returned 401');
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
name: 'admin can call an admin tool',
|
|
125
|
+
async run({ assert }) {
|
|
126
|
+
const key = process.env.BACKEND_MANAGER_KEY;
|
|
127
|
+
const response = await mcpRequest('tools/call', {
|
|
128
|
+
name: 'health_check',
|
|
129
|
+
arguments: {},
|
|
130
|
+
}, key);
|
|
131
|
+
|
|
132
|
+
assert.ok(response?.result, 'Should return a result');
|
|
133
|
+
assert.ok(!response.result.isError, 'Should not be an error');
|
|
134
|
+
assert.ok(response.result.content?.length > 0, 'Should have content');
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
name: 'user cannot call an admin tool',
|
|
140
|
+
async run({ assert, accounts }) {
|
|
141
|
+
const userKey = accounts.basic?.privateKey;
|
|
142
|
+
const response = await mcpRequest('tools/call', {
|
|
143
|
+
name: 'firestore_read',
|
|
144
|
+
arguments: { path: 'users/test' },
|
|
145
|
+
}, userKey);
|
|
146
|
+
|
|
147
|
+
assert.ok(response?.result, 'Should return a result');
|
|
148
|
+
assert.equal(response.result.isError, true, 'Should be an error');
|
|
149
|
+
assert.ok(response.result.content?.[0]?.text?.includes('Unknown tool'), 'Should say unknown tool');
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
name: 'unauthenticated cannot call any tool (gets 401)',
|
|
155
|
+
async run({ assert }) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await mcpRequest('tools/call', {
|
|
158
|
+
name: 'get_user',
|
|
159
|
+
arguments: {},
|
|
160
|
+
});
|
|
161
|
+
assert.equal(response?.error, 'Unauthorized', 'Should return Unauthorized');
|
|
162
|
+
} catch (error) {
|
|
163
|
+
assert.ok(true, 'Unauthenticated tool call returned 401');
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|