@unifiedmemory/cli 1.3.1 → 1.3.7
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/.github/workflows/test-and-publish.yml +96 -0
- package/index.js +8 -2
- package/lib/welcome.js +32 -4
- package/package.json +16 -4
- package/tests/fixtures/expired-auth.json +20 -0
- package/tests/fixtures/mock-auth.json +21 -0
- package/tests/fixtures/mock-project-config.json +12 -0
- package/tests/mocks/api.mock.js +200 -0
- package/tests/mocks/token-storage.mock.js +79 -0
- package/tests/setup.js +29 -0
- package/tests/unit/config.test.js +165 -0
- package/tests/unit/jwt-utils.test.js +217 -0
- package/tests/unit/mcp-proxy.test.js +459 -0
- package/tests/unit/provider-detector.test.js +344 -0
- package/tests/unit/token-storage.test.js +138 -0
- package/vitest.config.js +37 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for lib/mcp-proxy.js
|
|
3
|
+
*
|
|
4
|
+
* Tests MCP proxy functions including tool schema transformation
|
|
5
|
+
* and context parameter injection.
|
|
6
|
+
*
|
|
7
|
+
* Note: The internal transformToolSchema and injectContextParams functions
|
|
8
|
+
* are tested indirectly through the exported functions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
|
|
12
|
+
import { setupServer } from 'msw/node';
|
|
13
|
+
import { http, HttpResponse } from 'msw';
|
|
14
|
+
|
|
15
|
+
const GATEWAY_MCP_URL = 'https://rose-asp-main-1c0b114.d2.zuplo.dev/mcp';
|
|
16
|
+
|
|
17
|
+
// Sample tool with pathParams and headers that should be transformed
|
|
18
|
+
const sampleToolWithContext = {
|
|
19
|
+
name: 'create_note',
|
|
20
|
+
description: 'Create a new note',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
content: { type: 'string', description: 'Note content' },
|
|
25
|
+
pathParams: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
org: { type: 'string', description: 'Organization ID' },
|
|
29
|
+
proj: { type: 'string', description: 'Project ID' },
|
|
30
|
+
user: { type: 'string', description: 'User ID' },
|
|
31
|
+
},
|
|
32
|
+
required: ['org', 'proj', 'user'],
|
|
33
|
+
},
|
|
34
|
+
headers: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
'X-Org-Id': { type: 'string' },
|
|
38
|
+
'X-User-Id': { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['content', 'pathParams', 'headers'],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Tool with no context params (should remain unchanged)
|
|
47
|
+
const sampleToolNoContext = {
|
|
48
|
+
name: 'get_health',
|
|
49
|
+
description: 'Check health status',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
verbose: { type: 'boolean', description: 'Include details' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Setup MSW server for mocking fetch
|
|
59
|
+
const server = setupServer();
|
|
60
|
+
|
|
61
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
|
62
|
+
afterEach(() => server.resetHandlers());
|
|
63
|
+
afterAll(() => server.close());
|
|
64
|
+
|
|
65
|
+
describe('mcp-proxy', () => {
|
|
66
|
+
describe('fetchRemoteMCPTools', () => {
|
|
67
|
+
it('should fetch tools and transform schemas to hide context params', async () => {
|
|
68
|
+
// Setup mock response
|
|
69
|
+
server.use(
|
|
70
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
71
|
+
return HttpResponse.json({
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
id: 1,
|
|
74
|
+
result: {
|
|
75
|
+
tools: [sampleToolWithContext, sampleToolNoContext],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
82
|
+
|
|
83
|
+
const result = await fetchRemoteMCPTools({
|
|
84
|
+
Authorization: 'Bearer test_token',
|
|
85
|
+
'X-Org-Id': 'org_123',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.tools).toHaveLength(2);
|
|
89
|
+
|
|
90
|
+
// First tool should have pathParams context removed
|
|
91
|
+
const transformedTool = result.tools[0];
|
|
92
|
+
expect(transformedTool.name).toBe('create_note');
|
|
93
|
+
|
|
94
|
+
// org, proj, user should be removed from pathParams
|
|
95
|
+
const pathParamProps = transformedTool.inputSchema.properties.pathParams?.properties || {};
|
|
96
|
+
expect(pathParamProps.org).toBeUndefined();
|
|
97
|
+
expect(pathParamProps.proj).toBeUndefined();
|
|
98
|
+
expect(pathParamProps.user).toBeUndefined();
|
|
99
|
+
|
|
100
|
+
// headers should be removed entirely
|
|
101
|
+
expect(transformedTool.inputSchema.properties.headers).toBeUndefined();
|
|
102
|
+
|
|
103
|
+
// headers should be removed from required array
|
|
104
|
+
expect(transformedTool.inputSchema.required).not.toContain('headers');
|
|
105
|
+
|
|
106
|
+
// Second tool should remain relatively unchanged
|
|
107
|
+
const healthTool = result.tools[1];
|
|
108
|
+
expect(healthTool.name).toBe('get_health');
|
|
109
|
+
expect(healthTool.inputSchema.properties.verbose).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should handle empty tools list', async () => {
|
|
113
|
+
server.use(
|
|
114
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
115
|
+
return HttpResponse.json({
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id: 1,
|
|
118
|
+
result: { tools: [] },
|
|
119
|
+
});
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
124
|
+
|
|
125
|
+
const result = await fetchRemoteMCPTools({
|
|
126
|
+
Authorization: 'Bearer test_token',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.tools).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should throw error on 401 unauthorized response', async () => {
|
|
133
|
+
server.use(
|
|
134
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
135
|
+
return HttpResponse.json(
|
|
136
|
+
{ error: 'Unauthorized' },
|
|
137
|
+
{ status: 401 }
|
|
138
|
+
);
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
fetchRemoteMCPTools({ Authorization: 'Bearer invalid_token' })
|
|
146
|
+
).rejects.toThrow('Authentication failed');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should throw error on 403 forbidden response', async () => {
|
|
150
|
+
server.use(
|
|
151
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
152
|
+
return HttpResponse.json(
|
|
153
|
+
{ error: 'Forbidden' },
|
|
154
|
+
{ status: 403 }
|
|
155
|
+
);
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
160
|
+
|
|
161
|
+
await expect(
|
|
162
|
+
fetchRemoteMCPTools({ Authorization: 'Bearer test_token' })
|
|
163
|
+
).rejects.toThrow('Access denied');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should throw error on MCP protocol error response', async () => {
|
|
167
|
+
server.use(
|
|
168
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
169
|
+
return HttpResponse.json({
|
|
170
|
+
jsonrpc: '2.0',
|
|
171
|
+
id: 1,
|
|
172
|
+
error: { code: -32600, message: 'Invalid Request' },
|
|
173
|
+
});
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
fetchRemoteMCPTools({ Authorization: 'Bearer test_token' })
|
|
181
|
+
).rejects.toThrow('MCP error');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should throw connection error on network failure', async () => {
|
|
185
|
+
server.use(
|
|
186
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
187
|
+
return HttpResponse.error();
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
fetchRemoteMCPTools({ Authorization: 'Bearer test_token' })
|
|
195
|
+
).rejects.toThrow();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('callRemoteMCPTool', () => {
|
|
200
|
+
it('should inject context params into tool call', async () => {
|
|
201
|
+
let capturedBody = null;
|
|
202
|
+
|
|
203
|
+
server.use(
|
|
204
|
+
http.post(GATEWAY_MCP_URL, async ({ request }) => {
|
|
205
|
+
capturedBody = await request.json();
|
|
206
|
+
return HttpResponse.json({
|
|
207
|
+
jsonrpc: '2.0',
|
|
208
|
+
id: 1,
|
|
209
|
+
result: {
|
|
210
|
+
content: [{ type: 'text', text: 'Success' }],
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const { callRemoteMCPTool } = await import('../../lib/mcp-proxy.js');
|
|
217
|
+
|
|
218
|
+
const authHeaders = {
|
|
219
|
+
Authorization: 'Bearer test_token',
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const authContext = {
|
|
223
|
+
decoded: { sub: 'user_123' },
|
|
224
|
+
selectedOrg: { id: 'org_456' },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const projectContext = {
|
|
228
|
+
project_id: 'proj_789',
|
|
229
|
+
org_id: 'org_456',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await callRemoteMCPTool(
|
|
233
|
+
'create_note',
|
|
234
|
+
{ content: 'Test note' },
|
|
235
|
+
authHeaders,
|
|
236
|
+
authContext,
|
|
237
|
+
projectContext
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Verify context was injected
|
|
241
|
+
expect(capturedBody.params.arguments.pathParams.user).toBe('user_123');
|
|
242
|
+
expect(capturedBody.params.arguments.pathParams.org).toBe('org_456');
|
|
243
|
+
expect(capturedBody.params.arguments.pathParams.proj).toBe('proj_789');
|
|
244
|
+
expect(capturedBody.params.arguments.headers['X-User-Id']).toBe('user_123');
|
|
245
|
+
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('org_456');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should fallback to user_id for org when no selectedOrg', async () => {
|
|
249
|
+
let capturedBody = null;
|
|
250
|
+
|
|
251
|
+
server.use(
|
|
252
|
+
http.post(GATEWAY_MCP_URL, async ({ request }) => {
|
|
253
|
+
capturedBody = await request.json();
|
|
254
|
+
return HttpResponse.json({
|
|
255
|
+
jsonrpc: '2.0',
|
|
256
|
+
id: 1,
|
|
257
|
+
result: { content: [] },
|
|
258
|
+
});
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const { callRemoteMCPTool } = await import('../../lib/mcp-proxy.js');
|
|
263
|
+
|
|
264
|
+
const authContext = {
|
|
265
|
+
decoded: { sub: 'user_123' },
|
|
266
|
+
// No selectedOrg
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
await callRemoteMCPTool(
|
|
270
|
+
'create_note',
|
|
271
|
+
{},
|
|
272
|
+
{ Authorization: 'Bearer test' },
|
|
273
|
+
authContext,
|
|
274
|
+
null
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Org should fallback to user_id
|
|
278
|
+
expect(capturedBody.params.arguments.pathParams.org).toBe('user_123');
|
|
279
|
+
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('user_123');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should return tool execution result', async () => {
|
|
283
|
+
server.use(
|
|
284
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
285
|
+
return HttpResponse.json({
|
|
286
|
+
jsonrpc: '2.0',
|
|
287
|
+
id: 1,
|
|
288
|
+
result: {
|
|
289
|
+
content: [
|
|
290
|
+
{ type: 'text', text: 'Note created successfully' },
|
|
291
|
+
{ type: 'text', text: 'Note ID: note_123' },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const { callRemoteMCPTool } = await import('../../lib/mcp-proxy.js');
|
|
299
|
+
|
|
300
|
+
const result = await callRemoteMCPTool(
|
|
301
|
+
'create_note',
|
|
302
|
+
{ content: 'Test' },
|
|
303
|
+
{ Authorization: 'Bearer test' },
|
|
304
|
+
{ decoded: { sub: 'user_123' } },
|
|
305
|
+
null
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(result.content).toHaveLength(2);
|
|
309
|
+
expect(result.content[0].text).toBe('Note created successfully');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should throw error on tool execution failure', async () => {
|
|
313
|
+
server.use(
|
|
314
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
315
|
+
return HttpResponse.json({
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
id: 1,
|
|
318
|
+
error: { code: -32000, message: 'Note creation failed: duplicate content' },
|
|
319
|
+
});
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const { callRemoteMCPTool } = await import('../../lib/mcp-proxy.js');
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
callRemoteMCPTool(
|
|
327
|
+
'create_note',
|
|
328
|
+
{ content: 'Duplicate' },
|
|
329
|
+
{ Authorization: 'Bearer test' },
|
|
330
|
+
{ decoded: { sub: 'user_123' } },
|
|
331
|
+
null
|
|
332
|
+
)
|
|
333
|
+
).rejects.toThrow('Tool execution failed');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should throw 401 error message for unauthorized', async () => {
|
|
337
|
+
server.use(
|
|
338
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
339
|
+
return HttpResponse.json(
|
|
340
|
+
{ error: 'Token expired' },
|
|
341
|
+
{ status: 401 }
|
|
342
|
+
);
|
|
343
|
+
})
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const { callRemoteMCPTool } = await import('../../lib/mcp-proxy.js');
|
|
347
|
+
|
|
348
|
+
await expect(
|
|
349
|
+
callRemoteMCPTool(
|
|
350
|
+
'create_note',
|
|
351
|
+
{},
|
|
352
|
+
{ Authorization: 'Bearer expired' },
|
|
353
|
+
{ decoded: { sub: 'user_123' } },
|
|
354
|
+
null
|
|
355
|
+
)
|
|
356
|
+
).rejects.toThrow('um login');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('schema transformation edge cases', () => {
|
|
361
|
+
it('should handle tool with pathParams but empty after context removal', async () => {
|
|
362
|
+
const toolWithOnlyContextParams = {
|
|
363
|
+
name: 'context_only_tool',
|
|
364
|
+
description: 'Tool with only context params',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: {
|
|
368
|
+
pathParams: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
org: { type: 'string' },
|
|
372
|
+
proj: { type: 'string' },
|
|
373
|
+
},
|
|
374
|
+
required: ['org', 'proj'],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
required: ['pathParams'],
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
server.use(
|
|
382
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
383
|
+
return HttpResponse.json({
|
|
384
|
+
jsonrpc: '2.0',
|
|
385
|
+
id: 1,
|
|
386
|
+
result: { tools: [toolWithOnlyContextParams] },
|
|
387
|
+
});
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
392
|
+
|
|
393
|
+
const result = await fetchRemoteMCPTools({
|
|
394
|
+
Authorization: 'Bearer test',
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const transformed = result.tools[0];
|
|
398
|
+
|
|
399
|
+
// pathParams should be removed entirely since it's empty after context removal
|
|
400
|
+
expect(transformed.inputSchema.properties.pathParams).toBeUndefined();
|
|
401
|
+
|
|
402
|
+
// pathParams should also be removed from required array (or required should be undefined/empty)
|
|
403
|
+
if (transformed.inputSchema.required) {
|
|
404
|
+
expect(transformed.inputSchema.required).not.toContain('pathParams');
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should preserve non-context pathParams properties', async () => {
|
|
409
|
+
const toolWithMixedParams = {
|
|
410
|
+
name: 'mixed_params_tool',
|
|
411
|
+
description: 'Tool with mixed params',
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {
|
|
415
|
+
pathParams: {
|
|
416
|
+
type: 'object',
|
|
417
|
+
properties: {
|
|
418
|
+
org: { type: 'string' },
|
|
419
|
+
noteId: { type: 'string', description: 'Note ID' },
|
|
420
|
+
version: { type: 'number', description: 'Version number' },
|
|
421
|
+
},
|
|
422
|
+
required: ['org', 'noteId'],
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
required: ['pathParams'],
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
server.use(
|
|
430
|
+
http.post(GATEWAY_MCP_URL, () => {
|
|
431
|
+
return HttpResponse.json({
|
|
432
|
+
jsonrpc: '2.0',
|
|
433
|
+
id: 1,
|
|
434
|
+
result: { tools: [toolWithMixedParams] },
|
|
435
|
+
});
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const { fetchRemoteMCPTools } = await import('../../lib/mcp-proxy.js');
|
|
440
|
+
|
|
441
|
+
const result = await fetchRemoteMCPTools({
|
|
442
|
+
Authorization: 'Bearer test',
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const transformed = result.tools[0];
|
|
446
|
+
|
|
447
|
+
// noteId and version should be preserved
|
|
448
|
+
expect(transformed.inputSchema.properties.pathParams.properties.noteId).toBeDefined();
|
|
449
|
+
expect(transformed.inputSchema.properties.pathParams.properties.version).toBeDefined();
|
|
450
|
+
|
|
451
|
+
// org should be removed
|
|
452
|
+
expect(transformed.inputSchema.properties.pathParams.properties.org).toBeUndefined();
|
|
453
|
+
|
|
454
|
+
// required should only have noteId (org removed)
|
|
455
|
+
expect(transformed.inputSchema.properties.pathParams.required).toContain('noteId');
|
|
456
|
+
expect(transformed.inputSchema.properties.pathParams.required).not.toContain('org');
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|