@lobehub/lobehub 2.0.0-next.20 → 2.0.0-next.22
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/claude-auto-testing.yml +73 -0
- package/.github/workflows/claude-translate-comments.yml +67 -0
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/test.yml +39 -2
- package/CHANGELOG.md +42 -0
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/src/main/controllers/AuthCtr.ts +53 -39
- package/apps/desktop/src/main/controllers/MenuCtr.ts +5 -5
- package/apps/desktop/src/main/controllers/NotificationCtr.ts +29 -29
- package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +16 -16
- package/apps/desktop/src/main/controllers/ShortcutCtr.ts +2 -2
- package/apps/desktop/src/main/controllers/TrayMenuCtr.ts +18 -18
- package/apps/desktop/src/main/controllers/UpdaterCtr.ts +4 -4
- package/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts +706 -0
- package/apps/desktop/src/main/controllers/__tests__/TrayMenuCtr.test.ts +5 -5
- package/apps/desktop/src/main/controllers/index.ts +4 -4
- package/changelog/v1.json +14 -0
- package/docs/development/database-schema.dbml +2 -1
- package/package.json +2 -2
- package/packages/database/migrations/0042_improve_agent_index.sql +1 -0
- package/packages/database/migrations/meta/0042_snapshot.json +7800 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +8 -0
- package/packages/database/src/models/agent.ts +16 -13
- package/packages/database/src/models/session.ts +20 -9
- package/packages/database/src/models/user.ts +2 -1
- package/packages/database/src/schemas/agent.ts +4 -1
- package/packages/types/src/message/ui/params.ts +1 -1
- package/packages/utils/src/apiKey.test.ts +139 -0
- package/packages/utils/src/client/clipboard.ts +2 -2
- package/packages/utils/src/client/exportFile.ts +10 -10
- package/packages/utils/src/client/parserPlaceholder.ts +18 -18
- package/packages/utils/src/client/topic.ts +10 -10
- package/packages/utils/src/client/xor-obfuscation.ts +11 -11
- package/renovate.json +20 -3
- package/src/app/[variants]/oauth/consent/[uid]/Login.tsx +10 -1
- package/src/server/routers/lambda/message.ts +0 -2
- package/src/server/routers/lambda/user.ts +8 -6
- package/src/services/chat/index.ts +3 -3
- package/src/services/mcp.test.ts +777 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { ChatToolPayload } from '@lobechat/types';
|
|
2
|
+
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
|
|
3
|
+
import { act } from '@testing-library/react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { mcpService } from './mcp';
|
|
7
|
+
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('@lobechat/const', () => ({
|
|
10
|
+
CURRENT_VERSION: '1.0.0',
|
|
11
|
+
isDesktop: false,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('@lobechat/utils', () => ({
|
|
15
|
+
isLocalOrPrivateUrl: vi.fn((url: string) => {
|
|
16
|
+
return url.includes('127.0.0.1') || url.includes('localhost') || url.includes('192.168.');
|
|
17
|
+
}),
|
|
18
|
+
safeParseJSON: vi.fn((str: string) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(str);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('@/libs/trpc/client', () => ({
|
|
28
|
+
desktopClient: {
|
|
29
|
+
mcp: {
|
|
30
|
+
callTool: {
|
|
31
|
+
mutate: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
getStreamableMcpServerManifest: {
|
|
34
|
+
query: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
getStdioMcpServerManifest: {
|
|
37
|
+
query: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
validMcpServerInstallable: {
|
|
40
|
+
mutate: vi.fn(),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
toolsClient: {
|
|
45
|
+
mcp: {
|
|
46
|
+
callTool: {
|
|
47
|
+
mutate: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
getStreamableMcpServerManifest: {
|
|
50
|
+
query: vi.fn(),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock('./discover', () => ({
|
|
57
|
+
discoverService: {
|
|
58
|
+
reportPluginCall: vi.fn().mockResolvedValue(undefined),
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Mock tool store
|
|
63
|
+
const mockGetToolStoreState = vi.fn();
|
|
64
|
+
const mockPluginSelectors = {
|
|
65
|
+
getInstalledPluginById: vi.fn(),
|
|
66
|
+
getCustomPluginById: vi.fn(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
vi.mock('@/store/tool/store', () => ({
|
|
70
|
+
getToolStoreState: () => mockGetToolStoreState(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
vi.mock('@/store/tool/selectors', () => ({
|
|
74
|
+
pluginSelectors: mockPluginSelectors,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
describe('MCPService', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
vi.resetModules();
|
|
81
|
+
mockGetToolStoreState.mockReturnValue({});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('invokeMcpToolCall', () => {
|
|
85
|
+
it('should invoke tool call with installed plugin', async () => {
|
|
86
|
+
const { desktopClient, toolsClient } = await import('@/libs/trpc/client');
|
|
87
|
+
const { discoverService } = await import('./discover');
|
|
88
|
+
|
|
89
|
+
const mockPlugin = {
|
|
90
|
+
customParams: {
|
|
91
|
+
mcp: {
|
|
92
|
+
type: 'sse',
|
|
93
|
+
name: 'test-plugin',
|
|
94
|
+
env: { API_KEY: 'test-key' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
settings: { timeout: 5000 },
|
|
98
|
+
manifest: {
|
|
99
|
+
meta: {
|
|
100
|
+
avatar: '🧪',
|
|
101
|
+
description: 'Test plugin',
|
|
102
|
+
title: 'Test Plugin',
|
|
103
|
+
},
|
|
104
|
+
version: '1.0.0',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => mockPlugin);
|
|
109
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
110
|
+
|
|
111
|
+
const mockResult = 'test result';
|
|
112
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue(mockResult);
|
|
113
|
+
|
|
114
|
+
const payload: ChatToolPayload = {
|
|
115
|
+
id: 'tool-call-1',
|
|
116
|
+
identifier: 'test-plugin',
|
|
117
|
+
apiName: 'testMethod',
|
|
118
|
+
arguments: '{"param": "value"}',
|
|
119
|
+
type: 'standalone',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await mcpService.invokeMcpToolCall(payload, { topicId: 'topic-1' });
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual(mockResult);
|
|
125
|
+
expect(toolsClient.mcp.callTool.mutate).toHaveBeenCalledWith(
|
|
126
|
+
{
|
|
127
|
+
args: '{"param": "value"}',
|
|
128
|
+
env: { timeout: 5000 },
|
|
129
|
+
params: {
|
|
130
|
+
type: 'sse',
|
|
131
|
+
name: 'test-plugin',
|
|
132
|
+
env: { API_KEY: 'test-key' },
|
|
133
|
+
},
|
|
134
|
+
toolName: 'testMethod',
|
|
135
|
+
},
|
|
136
|
+
{ signal: undefined },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Wait for async reporting to complete
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
141
|
+
|
|
142
|
+
expect(discoverService.reportPluginCall).toHaveBeenCalled();
|
|
143
|
+
const reportCall = vi.mocked(discoverService.reportPluginCall).mock.calls[0][0];
|
|
144
|
+
expect(reportCall).toMatchObject({
|
|
145
|
+
identifier: 'test-plugin',
|
|
146
|
+
methodName: 'testMethod',
|
|
147
|
+
success: true,
|
|
148
|
+
isCustomPlugin: false,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should invoke tool call with custom plugin', async () => {
|
|
153
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
154
|
+
|
|
155
|
+
const mockCustomPlugin = {
|
|
156
|
+
customParams: {
|
|
157
|
+
mcp: {
|
|
158
|
+
type: 'streamable',
|
|
159
|
+
name: 'custom-plugin',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
manifest: {
|
|
163
|
+
meta: {
|
|
164
|
+
avatar: '🎨',
|
|
165
|
+
description: 'Custom plugin',
|
|
166
|
+
title: 'Custom Plugin',
|
|
167
|
+
},
|
|
168
|
+
version: '2.0.0',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => null);
|
|
173
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => mockCustomPlugin);
|
|
174
|
+
|
|
175
|
+
const mockResult = 'custom result';
|
|
176
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue(mockResult);
|
|
177
|
+
|
|
178
|
+
const payload: ChatToolPayload = {
|
|
179
|
+
id: 'tool-call-2',
|
|
180
|
+
identifier: 'custom-plugin',
|
|
181
|
+
apiName: 'customMethod',
|
|
182
|
+
arguments: '{}',
|
|
183
|
+
type: 'standalone',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await mcpService.invokeMcpToolCall(payload, {});
|
|
187
|
+
|
|
188
|
+
expect(result).toEqual(mockResult);
|
|
189
|
+
expect(toolsClient.mcp.callTool.mutate).toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should use toolsClient for stdio plugin when not on desktop', async () => {
|
|
193
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
194
|
+
|
|
195
|
+
const mockStdioPlugin = {
|
|
196
|
+
customParams: {
|
|
197
|
+
mcp: {
|
|
198
|
+
type: 'stdio',
|
|
199
|
+
command: 'node',
|
|
200
|
+
args: ['script.js'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
settings: {},
|
|
204
|
+
manifest: {
|
|
205
|
+
meta: { title: 'Stdio Plugin' },
|
|
206
|
+
version: '1.0.0',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => mockStdioPlugin);
|
|
211
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
212
|
+
|
|
213
|
+
const mockResult = 'stdio result';
|
|
214
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue(mockResult);
|
|
215
|
+
|
|
216
|
+
const payload: ChatToolPayload = {
|
|
217
|
+
id: 'tool-call-3',
|
|
218
|
+
identifier: 'stdio-plugin',
|
|
219
|
+
apiName: 'execute',
|
|
220
|
+
arguments: '{"input": "test"}',
|
|
221
|
+
type: 'standalone',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const result = await mcpService.invokeMcpToolCall(payload, {});
|
|
225
|
+
|
|
226
|
+
expect(result).toEqual(mockResult);
|
|
227
|
+
expect(toolsClient.mcp.callTool.mutate).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should return undefined when plugin is not found', async () => {
|
|
231
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => null);
|
|
232
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
233
|
+
|
|
234
|
+
const payload: ChatToolPayload = {
|
|
235
|
+
id: 'tool-call-4',
|
|
236
|
+
identifier: 'non-existent-plugin',
|
|
237
|
+
apiName: 'method',
|
|
238
|
+
arguments: '{}',
|
|
239
|
+
type: 'standalone',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const result = await mcpService.invokeMcpToolCall(payload, {});
|
|
243
|
+
|
|
244
|
+
expect(result).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle tool call errors and report them', async () => {
|
|
248
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
249
|
+
const { discoverService } = await import('./discover');
|
|
250
|
+
|
|
251
|
+
const mockPlugin = {
|
|
252
|
+
customParams: {
|
|
253
|
+
mcp: {
|
|
254
|
+
type: 'sse',
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
manifest: {
|
|
258
|
+
meta: { title: 'Error Plugin' },
|
|
259
|
+
version: '1.0.0',
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => mockPlugin);
|
|
264
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
265
|
+
|
|
266
|
+
const mockError = new Error('Tool call failed');
|
|
267
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockRejectedValue(mockError);
|
|
268
|
+
|
|
269
|
+
const payload: ChatToolPayload = {
|
|
270
|
+
id: 'tool-call-5',
|
|
271
|
+
identifier: 'error-plugin',
|
|
272
|
+
apiName: 'failMethod',
|
|
273
|
+
arguments: '{}',
|
|
274
|
+
type: 'standalone',
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await expect(mcpService.invokeMcpToolCall(payload, {})).rejects.toThrow('Tool call failed');
|
|
278
|
+
|
|
279
|
+
// Wait for async reporting to complete
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
281
|
+
|
|
282
|
+
expect(discoverService.reportPluginCall).toHaveBeenCalled();
|
|
283
|
+
const reportCall = vi.mocked(discoverService.reportPluginCall).mock.calls[0][0];
|
|
284
|
+
expect(reportCall).toMatchObject({
|
|
285
|
+
success: false,
|
|
286
|
+
errorCode: 'CALL_FAILED',
|
|
287
|
+
errorMessage: 'Tool call failed',
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should calculate request and response sizes correctly', async () => {
|
|
292
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
293
|
+
const { discoverService } = await import('./discover');
|
|
294
|
+
|
|
295
|
+
const mockPlugin = {
|
|
296
|
+
customParams: {
|
|
297
|
+
mcp: { type: 'sse' },
|
|
298
|
+
},
|
|
299
|
+
manifest: {
|
|
300
|
+
meta: { title: 'Size Test Plugin' },
|
|
301
|
+
version: '1.0.0',
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => mockPlugin);
|
|
306
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
307
|
+
|
|
308
|
+
const mockResult = 'response data';
|
|
309
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue(mockResult);
|
|
310
|
+
|
|
311
|
+
const payload: ChatToolPayload = {
|
|
312
|
+
id: 'tool-call-6',
|
|
313
|
+
identifier: 'size-plugin',
|
|
314
|
+
apiName: 'sizeMethod',
|
|
315
|
+
arguments: '{"key": "value"}',
|
|
316
|
+
type: 'standalone',
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
await mcpService.invokeMcpToolCall(payload, {});
|
|
320
|
+
|
|
321
|
+
// Wait for async reporting
|
|
322
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
323
|
+
|
|
324
|
+
expect(discoverService.reportPluginCall).toHaveBeenCalled();
|
|
325
|
+
const reportCall = vi.mocked(discoverService.reportPluginCall).mock.calls[0][0];
|
|
326
|
+
expect(reportCall.requestSizeBytes).toBeGreaterThan(0);
|
|
327
|
+
expect(reportCall.responseSizeBytes).toBeGreaterThan(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should handle abort signal', async () => {
|
|
331
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
332
|
+
|
|
333
|
+
const mockPlugin = {
|
|
334
|
+
customParams: {
|
|
335
|
+
mcp: { type: 'sse' },
|
|
336
|
+
},
|
|
337
|
+
manifest: {
|
|
338
|
+
meta: { title: 'Abort Test Plugin' },
|
|
339
|
+
version: '1.0.0',
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => mockPlugin);
|
|
344
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => null);
|
|
345
|
+
|
|
346
|
+
const abortController = new AbortController();
|
|
347
|
+
const mockResult = 'result';
|
|
348
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue(mockResult);
|
|
349
|
+
|
|
350
|
+
const payload: ChatToolPayload = {
|
|
351
|
+
id: 'tool-call-7',
|
|
352
|
+
identifier: 'abort-plugin',
|
|
353
|
+
apiName: 'method',
|
|
354
|
+
arguments: '{}',
|
|
355
|
+
type: 'standalone',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const result = await mcpService.invokeMcpToolCall(payload, {
|
|
359
|
+
signal: abortController.signal,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(toolsClient.mcp.callTool.mutate).toHaveBeenCalledWith(
|
|
363
|
+
expect.anything(),
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
signal: abortController.signal,
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
expect(result).toEqual(mockResult);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should report custom plugin info correctly', async () => {
|
|
372
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
373
|
+
const { discoverService } = await import('./discover');
|
|
374
|
+
|
|
375
|
+
const mockCustomPlugin = {
|
|
376
|
+
customParams: {
|
|
377
|
+
mcp: {
|
|
378
|
+
type: 'streamable',
|
|
379
|
+
command: 'npm run plugin',
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
manifest: {
|
|
383
|
+
meta: {
|
|
384
|
+
avatar: '🔧',
|
|
385
|
+
description: 'Custom tool description',
|
|
386
|
+
title: 'Custom Tool',
|
|
387
|
+
},
|
|
388
|
+
version: '3.0.0',
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
mockPluginSelectors.getInstalledPluginById.mockReturnValue(() => null);
|
|
393
|
+
mockPluginSelectors.getCustomPluginById.mockReturnValue(() => mockCustomPlugin);
|
|
394
|
+
|
|
395
|
+
vi.mocked(toolsClient.mcp.callTool.mutate).mockResolvedValue('ok');
|
|
396
|
+
|
|
397
|
+
const payload: ChatToolPayload = {
|
|
398
|
+
id: 'tool-call-8',
|
|
399
|
+
identifier: 'custom-tool',
|
|
400
|
+
apiName: 'customAction',
|
|
401
|
+
arguments: '{}',
|
|
402
|
+
type: 'standalone',
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
await mcpService.invokeMcpToolCall(payload, {});
|
|
406
|
+
|
|
407
|
+
// Wait for async reporting
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
409
|
+
|
|
410
|
+
expect(discoverService.reportPluginCall).toHaveBeenCalled();
|
|
411
|
+
const reportCall = vi.mocked(discoverService.reportPluginCall).mock.calls[0][0];
|
|
412
|
+
expect(reportCall.isCustomPlugin).toBe(true);
|
|
413
|
+
expect(reportCall.customPluginInfo).toEqual({
|
|
414
|
+
avatar: '🔧',
|
|
415
|
+
description: 'Custom tool description',
|
|
416
|
+
name: 'Custom Tool',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('getStreamableMcpServerManifest', () => {
|
|
422
|
+
it('should use toolsClient for streamable URLs when not on desktop', async () => {
|
|
423
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
424
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
425
|
+
identifier: 'streamable-server',
|
|
426
|
+
version: '1',
|
|
427
|
+
meta: { title: 'Streamable MCP Server', avatar: '🌐' },
|
|
428
|
+
api: [
|
|
429
|
+
{
|
|
430
|
+
name: 'test',
|
|
431
|
+
description: 'Test API',
|
|
432
|
+
parameters: { type: 'object', properties: {} },
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
};
|
|
436
|
+
vi.mocked(toolsClient.mcp.getStreamableMcpServerManifest.query).mockResolvedValue(
|
|
437
|
+
mockManifest,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const params = {
|
|
441
|
+
identifier: 'streamable-server',
|
|
442
|
+
url: 'http://127.0.0.1:3000/manifest',
|
|
443
|
+
auth: { type: 'none' as const },
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const result = await mcpService.getStreamableMcpServerManifest(params);
|
|
447
|
+
|
|
448
|
+
expect(result).toEqual(mockManifest);
|
|
449
|
+
expect(toolsClient.mcp.getStreamableMcpServerManifest.query).toHaveBeenCalledWith(params, {
|
|
450
|
+
signal: undefined,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should use toolsClient for remote URLs', async () => {
|
|
455
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
456
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
457
|
+
identifier: 'remote-server',
|
|
458
|
+
version: '1',
|
|
459
|
+
meta: { title: 'Remote MCP Server', avatar: '🌍' },
|
|
460
|
+
api: [
|
|
461
|
+
{
|
|
462
|
+
name: 'remoteTest',
|
|
463
|
+
description: 'Remote Test API',
|
|
464
|
+
parameters: { type: 'object', properties: {} },
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
vi.mocked(toolsClient.mcp.getStreamableMcpServerManifest.query).mockResolvedValue(
|
|
469
|
+
mockManifest,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const params = {
|
|
473
|
+
identifier: 'remote-server',
|
|
474
|
+
url: 'https://api.example.com/manifest',
|
|
475
|
+
auth: { type: 'bearer' as const, token: 'abc123' },
|
|
476
|
+
headers: { 'X-Custom': 'header' },
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const abortController = new AbortController();
|
|
480
|
+
const result = await mcpService.getStreamableMcpServerManifest(
|
|
481
|
+
params,
|
|
482
|
+
abortController.signal,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(result).toEqual(mockManifest);
|
|
486
|
+
expect(toolsClient.mcp.getStreamableMcpServerManifest.query).toHaveBeenCalledWith(params, {
|
|
487
|
+
signal: abortController.signal,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should handle different URL formats correctly', async () => {
|
|
492
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
493
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
494
|
+
identifier: 'server',
|
|
495
|
+
version: '1',
|
|
496
|
+
meta: { title: 'URL Test Server', avatar: '🔗' },
|
|
497
|
+
api: [
|
|
498
|
+
{
|
|
499
|
+
name: 'urlTest',
|
|
500
|
+
description: 'URL Test API',
|
|
501
|
+
parameters: { type: 'object', properties: {} },
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
};
|
|
505
|
+
vi.mocked(toolsClient.mcp.getStreamableMcpServerManifest.query).mockResolvedValue(
|
|
506
|
+
mockManifest,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const params = {
|
|
510
|
+
identifier: 'server',
|
|
511
|
+
url: 'http://localhost:8080/manifest',
|
|
512
|
+
auth: { type: 'none' as const },
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const result = await mcpService.getStreamableMcpServerManifest(params);
|
|
516
|
+
|
|
517
|
+
expect(result).toEqual(mockManifest);
|
|
518
|
+
expect(toolsClient.mcp.getStreamableMcpServerManifest.query).toHaveBeenCalled();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('should handle OAuth2 authentication', async () => {
|
|
522
|
+
const { toolsClient } = await import('@/libs/trpc/client');
|
|
523
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
524
|
+
identifier: 'oauth-server',
|
|
525
|
+
version: '1',
|
|
526
|
+
meta: { title: 'OAuth Server', avatar: '🔐' },
|
|
527
|
+
api: [
|
|
528
|
+
{
|
|
529
|
+
name: 'oauthTest',
|
|
530
|
+
description: 'OAuth Test API',
|
|
531
|
+
parameters: { type: 'object', properties: {} },
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
};
|
|
535
|
+
vi.mocked(toolsClient.mcp.getStreamableMcpServerManifest.query).mockResolvedValue(
|
|
536
|
+
mockManifest,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const params = {
|
|
540
|
+
identifier: 'oauth-server',
|
|
541
|
+
url: 'https://api.oauth.com/manifest',
|
|
542
|
+
auth: {
|
|
543
|
+
type: 'oauth2' as const,
|
|
544
|
+
accessToken: 'access_token_123',
|
|
545
|
+
},
|
|
546
|
+
metadata: {
|
|
547
|
+
avatar: '🔐',
|
|
548
|
+
description: 'OAuth secured API',
|
|
549
|
+
name: 'OAuth API',
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const result = await mcpService.getStreamableMcpServerManifest(params);
|
|
554
|
+
|
|
555
|
+
expect(result).toEqual(mockManifest);
|
|
556
|
+
expect(toolsClient.mcp.getStreamableMcpServerManifest.query).toHaveBeenCalledWith(
|
|
557
|
+
params,
|
|
558
|
+
expect.any(Object),
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe('getStdioMcpServerManifest', () => {
|
|
564
|
+
it('should call desktopClient with stdio parameters', async () => {
|
|
565
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
566
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
567
|
+
identifier: 'stdio-server',
|
|
568
|
+
version: '1',
|
|
569
|
+
meta: { title: 'Stdio Server', avatar: '📦' },
|
|
570
|
+
api: [
|
|
571
|
+
{
|
|
572
|
+
name: 'stdioTest',
|
|
573
|
+
description: 'Stdio Test API',
|
|
574
|
+
parameters: { type: 'object', properties: {} },
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
};
|
|
578
|
+
vi.mocked(desktopClient.mcp.getStdioMcpServerManifest.query).mockResolvedValue(mockManifest);
|
|
579
|
+
|
|
580
|
+
const stdioParams = {
|
|
581
|
+
command: 'node',
|
|
582
|
+
args: ['server.js', '--port', '3000'],
|
|
583
|
+
env: { NODE_ENV: 'production', API_KEY: 'secret' },
|
|
584
|
+
name: 'stdio-server',
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const metadata = {
|
|
588
|
+
avatar: '📦',
|
|
589
|
+
description: 'Stdio API',
|
|
590
|
+
name: 'Stdio Server',
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const result = await mcpService.getStdioMcpServerManifest(stdioParams, metadata);
|
|
594
|
+
|
|
595
|
+
expect(result).toEqual(mockManifest);
|
|
596
|
+
expect(desktopClient.mcp.getStdioMcpServerManifest.query).toHaveBeenCalledWith(
|
|
597
|
+
{ ...stdioParams, metadata },
|
|
598
|
+
{ signal: undefined },
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should handle abort signal for stdio manifest', async () => {
|
|
603
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
604
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
605
|
+
identifier: 'python-server',
|
|
606
|
+
version: '1',
|
|
607
|
+
meta: { title: 'Stdio Server', avatar: '🐍' },
|
|
608
|
+
api: [
|
|
609
|
+
{
|
|
610
|
+
name: 'pythonTest',
|
|
611
|
+
description: 'Python Test API',
|
|
612
|
+
parameters: { type: 'object', properties: {} },
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
};
|
|
616
|
+
vi.mocked(desktopClient.mcp.getStdioMcpServerManifest.query).mockResolvedValue(mockManifest);
|
|
617
|
+
|
|
618
|
+
const stdioParams = {
|
|
619
|
+
command: 'python',
|
|
620
|
+
args: ['app.py'],
|
|
621
|
+
name: 'python-server',
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const abortController = new AbortController();
|
|
625
|
+
await mcpService.getStdioMcpServerManifest(stdioParams, undefined, abortController.signal);
|
|
626
|
+
|
|
627
|
+
expect(desktopClient.mcp.getStdioMcpServerManifest.query).toHaveBeenCalledWith(
|
|
628
|
+
{ ...stdioParams, metadata: undefined },
|
|
629
|
+
{ signal: abortController.signal },
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should work without optional parameters', async () => {
|
|
634
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
635
|
+
const mockManifest: LobeChatPluginManifest = {
|
|
636
|
+
identifier: 'npm-server',
|
|
637
|
+
version: '1',
|
|
638
|
+
meta: { title: 'Simple Server', avatar: '📦' },
|
|
639
|
+
api: [
|
|
640
|
+
{
|
|
641
|
+
name: 'npmTest',
|
|
642
|
+
description: 'NPM Test API',
|
|
643
|
+
parameters: { type: 'object', properties: {} },
|
|
644
|
+
},
|
|
645
|
+
],
|
|
646
|
+
};
|
|
647
|
+
vi.mocked(desktopClient.mcp.getStdioMcpServerManifest.query).mockResolvedValue(mockManifest);
|
|
648
|
+
|
|
649
|
+
const stdioParams = {
|
|
650
|
+
command: 'npm',
|
|
651
|
+
name: 'npm-server',
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const result = await mcpService.getStdioMcpServerManifest(stdioParams);
|
|
655
|
+
|
|
656
|
+
expect(result).toEqual(mockManifest);
|
|
657
|
+
expect(desktopClient.mcp.getStdioMcpServerManifest.query).toHaveBeenCalledWith(
|
|
658
|
+
{ ...stdioParams, metadata: undefined },
|
|
659
|
+
{ signal: undefined },
|
|
660
|
+
);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('checkInstallation', () => {
|
|
665
|
+
it('should check MCP plugin installation status', async () => {
|
|
666
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
667
|
+
const mockInstallResult = {
|
|
668
|
+
platform: 'linux',
|
|
669
|
+
success: true,
|
|
670
|
+
packageInstalled: true,
|
|
671
|
+
};
|
|
672
|
+
vi.mocked(desktopClient.mcp.validMcpServerInstallable.mutate).mockResolvedValue(
|
|
673
|
+
mockInstallResult,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const manifest = {
|
|
677
|
+
identifier: 'test-plugin',
|
|
678
|
+
meta: { title: 'Test Plugin' },
|
|
679
|
+
version: '1.0.0',
|
|
680
|
+
deploymentOptions: [
|
|
681
|
+
{
|
|
682
|
+
type: 'stdio',
|
|
683
|
+
command: 'npx',
|
|
684
|
+
args: ['-y', 'test-plugin'],
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const result = await mcpService.checkInstallation(manifest as any);
|
|
690
|
+
|
|
691
|
+
expect(result).toEqual(mockInstallResult);
|
|
692
|
+
expect(desktopClient.mcp.validMcpServerInstallable.mutate).toHaveBeenCalledWith(
|
|
693
|
+
{ deploymentOptions: manifest.deploymentOptions },
|
|
694
|
+
{ signal: undefined },
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('should handle installation check with abort signal', async () => {
|
|
699
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
700
|
+
const mockInstallResult = {
|
|
701
|
+
platform: 'linux',
|
|
702
|
+
success: false,
|
|
703
|
+
packageInstalled: false,
|
|
704
|
+
systemDependencies: [
|
|
705
|
+
{ name: 'node', installed: false, meetRequirement: false },
|
|
706
|
+
{ name: 'npm', installed: false, meetRequirement: false },
|
|
707
|
+
],
|
|
708
|
+
};
|
|
709
|
+
vi.mocked(desktopClient.mcp.validMcpServerInstallable.mutate).mockResolvedValue(
|
|
710
|
+
mockInstallResult,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const manifest = {
|
|
714
|
+
identifier: 'complex-plugin',
|
|
715
|
+
meta: { title: 'Complex Plugin' },
|
|
716
|
+
version: '2.0.0',
|
|
717
|
+
deploymentOptions: [
|
|
718
|
+
{
|
|
719
|
+
type: 'sse',
|
|
720
|
+
url: 'https://plugin.example.com',
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const abortController = new AbortController();
|
|
726
|
+
const result = await mcpService.checkInstallation(manifest as any, abortController.signal);
|
|
727
|
+
|
|
728
|
+
expect(result).toEqual(mockInstallResult);
|
|
729
|
+
expect(desktopClient.mcp.validMcpServerInstallable.mutate).toHaveBeenCalledWith(
|
|
730
|
+
{ deploymentOptions: manifest.deploymentOptions },
|
|
731
|
+
{ signal: abortController.signal },
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('should handle multiple deployment options', async () => {
|
|
736
|
+
const { desktopClient } = await import('@/libs/trpc/client');
|
|
737
|
+
const mockInstallResult = {
|
|
738
|
+
platform: 'linux',
|
|
739
|
+
success: true,
|
|
740
|
+
packageInstalled: true,
|
|
741
|
+
isRecommended: true,
|
|
742
|
+
};
|
|
743
|
+
vi.mocked(desktopClient.mcp.validMcpServerInstallable.mutate).mockResolvedValue(
|
|
744
|
+
mockInstallResult,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const manifest = {
|
|
748
|
+
identifier: 'multi-deploy-plugin',
|
|
749
|
+
meta: { title: 'Multi Deploy Plugin' },
|
|
750
|
+
version: '3.0.0',
|
|
751
|
+
deploymentOptions: [
|
|
752
|
+
{
|
|
753
|
+
type: 'stdio',
|
|
754
|
+
command: 'node',
|
|
755
|
+
args: ['index.js'],
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
type: 'streamable',
|
|
759
|
+
url: 'https://api.example.com',
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
type: 'sse',
|
|
763
|
+
url: 'https://sse.example.com',
|
|
764
|
+
},
|
|
765
|
+
],
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const result = await mcpService.checkInstallation(manifest as any);
|
|
769
|
+
|
|
770
|
+
expect(result).toEqual(mockInstallResult);
|
|
771
|
+
expect(desktopClient.mcp.validMcpServerInstallable.mutate).toHaveBeenCalledWith(
|
|
772
|
+
{ deploymentOptions: manifest.deploymentOptions },
|
|
773
|
+
expect.any(Object),
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|