@lobehub/chat 1.82.0 → 1.82.2

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.
Files changed (102) hide show
  1. package/.cursor/rules/desktop-local-tools-implement.mdc +80 -0
  2. package/.env.desktop +2 -1
  3. package/.github/scripts/pr-comment.js +4 -9
  4. package/CHANGELOG.md +51 -0
  5. package/changelog/v1.json +18 -0
  6. package/locales/ar/electron.json +38 -2
  7. package/locales/ar/plugin.json +51 -31
  8. package/locales/bg-BG/electron.json +38 -2
  9. package/locales/bg-BG/plugin.json +51 -31
  10. package/locales/de-DE/electron.json +38 -2
  11. package/locales/de-DE/plugin.json +29 -9
  12. package/locales/en-US/electron.json +38 -2
  13. package/locales/en-US/plugin.json +29 -9
  14. package/locales/es-ES/electron.json +38 -2
  15. package/locales/es-ES/plugin.json +51 -31
  16. package/locales/fa-IR/electron.json +38 -2
  17. package/locales/fa-IR/plugin.json +51 -31
  18. package/locales/fr-FR/electron.json +38 -2
  19. package/locales/fr-FR/plugin.json +51 -31
  20. package/locales/it-IT/electron.json +38 -2
  21. package/locales/it-IT/plugin.json +51 -31
  22. package/locales/ja-JP/electron.json +38 -2
  23. package/locales/ja-JP/plugin.json +51 -31
  24. package/locales/ko-KR/electron.json +38 -2
  25. package/locales/ko-KR/plugin.json +29 -9
  26. package/locales/nl-NL/electron.json +38 -2
  27. package/locales/nl-NL/plugin.json +51 -31
  28. package/locales/pl-PL/electron.json +38 -2
  29. package/locales/pl-PL/plugin.json +29 -9
  30. package/locales/pt-BR/electron.json +38 -2
  31. package/locales/pt-BR/plugin.json +51 -31
  32. package/locales/ru-RU/electron.json +38 -2
  33. package/locales/ru-RU/plugin.json +51 -31
  34. package/locales/tr-TR/electron.json +38 -2
  35. package/locales/tr-TR/plugin.json +51 -31
  36. package/locales/vi-VN/electron.json +38 -2
  37. package/locales/vi-VN/plugin.json +29 -9
  38. package/locales/zh-CN/electron.json +38 -2
  39. package/locales/zh-CN/plugin.json +30 -10
  40. package/locales/zh-TW/electron.json +38 -2
  41. package/locales/zh-TW/plugin.json +51 -31
  42. package/package.json +1 -1
  43. package/packages/electron-client-ipc/src/events/update.ts +3 -3
  44. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Mode.tsx +222 -0
  45. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Option.tsx +104 -0
  46. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Sync.tsx +42 -0
  47. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Waiting.tsx +203 -0
  48. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/index.tsx +57 -0
  49. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/UpdateModal.tsx +242 -0
  50. package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/UpdateNotification.tsx +193 -0
  51. package/src/app/[variants]/(main)/_layout/Desktop/{Titlebar.tsx → ElectronTitlebar/index.tsx} +15 -1
  52. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/BottomActions.tsx +3 -2
  53. package/src/app/[variants]/(main)/_layout/Desktop/index.tsx +1 -1
  54. package/src/app/[variants]/layout.tsx +2 -1
  55. package/src/config/aiModels/openrouter.ts +6 -6
  56. package/src/features/Conversation/components/MarkdownElements/LocalFile/Render/LocalFile.tsx +65 -0
  57. package/src/features/Conversation/components/MarkdownElements/LocalFile/Render/index.tsx +29 -0
  58. package/src/features/Conversation/components/MarkdownElements/LocalFile/index.ts +16 -0
  59. package/src/features/Conversation/components/MarkdownElements/index.ts +7 -1
  60. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/__snapshots__/createRemarkSelfClosingTagPlugin.test.ts.snap +260 -0
  61. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/createRemarkSelfClosingTagPlugin.test.ts +204 -0
  62. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/createRemarkSelfClosingTagPlugin.ts +133 -0
  63. package/src/features/Conversation/components/MarkdownElements/type.ts +5 -1
  64. package/src/features/PluginDevModal/MCPManifestForm/ArgsInput.tsx +20 -0
  65. package/src/features/PluginDevModal/MCPManifestForm/MCPTypeSelect.tsx +176 -0
  66. package/src/features/PluginDevModal/MCPManifestForm/index.tsx +289 -0
  67. package/src/features/PluginDevModal/MCPManifestForm/utils.test.ts +262 -0
  68. package/src/features/PluginDevModal/MCPManifestForm/utils.ts +151 -0
  69. package/src/features/PluginDevModal/index.tsx +31 -22
  70. package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.ts +1 -1
  71. package/src/libs/mcp/__tests__/__snapshots__/index.test.ts.snap +0 -56
  72. package/src/locales/default/electron.ts +38 -2
  73. package/src/locales/default/plugin.ts +28 -8
  74. package/src/server/modules/ElectronIPCClient/index.ts +36 -0
  75. package/src/server/routers/lambda/session.ts +2 -6
  76. package/src/server/routers/tools/mcp.ts +6 -0
  77. package/src/server/services/file/impls/index.ts +9 -1
  78. package/src/server/services/file/impls/local.test.ts +299 -0
  79. package/src/server/services/file/impls/local.ts +183 -0
  80. package/src/server/services/mcp/index.ts +26 -0
  81. package/src/services/aiModel/index.ts +5 -1
  82. package/src/services/aiProvider/index.ts +5 -1
  83. package/src/services/electron/autoUpdate.ts +4 -0
  84. package/src/services/file/index.ts +5 -1
  85. package/src/services/mcp.ts +13 -2
  86. package/src/services/message/index.ts +5 -1
  87. package/src/services/plugin/index.ts +5 -1
  88. package/src/services/session/index.ts +5 -1
  89. package/src/services/tableViewer/desktop.ts +15 -0
  90. package/src/services/tableViewer/index.ts +4 -1
  91. package/src/services/thread/index.ts +5 -1
  92. package/src/services/topic/index.ts +5 -1
  93. package/src/services/user/index.ts +5 -1
  94. package/src/store/electron/actions/app.ts +59 -0
  95. package/src/store/electron/actions/sync.ts +5 -1
  96. package/src/store/electron/initialState.ts +3 -1
  97. package/src/store/electron/store.ts +6 -1
  98. package/src/store/tool/slices/customPlugin/action.ts +16 -4
  99. package/src/utils/client/GlobalAgentContextManager.ts +85 -0
  100. package/src/utils/promptTemplate.test.ts +78 -0
  101. package/src/utils/promptTemplate.ts +17 -0
  102. package/src/features/PluginDevModal/MCPManifestForm.tsx +0 -164
@@ -0,0 +1,289 @@
1
+ import {
2
+ SiBun,
3
+ SiDocker,
4
+ SiNodedotjs,
5
+ SiNpm,
6
+ SiPnpm,
7
+ SiPython,
8
+ } from '@icons-pack/react-simple-icons';
9
+ import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
10
+ import { Alert, FormItem, Icon } from '@lobehub/ui';
11
+ import { AutoComplete, Button, Form, FormInstance, Input } from 'antd';
12
+ import { FileCode } from 'lucide-react';
13
+ import { ChangeEvent, FC, useState } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { Flexbox } from 'react-layout-kit';
16
+
17
+ import ManifestPreviewer from '@/components/ManifestPreviewer';
18
+ import { isDesktop } from '@/const/version';
19
+ import { mcpService } from '@/services/mcp';
20
+ import { useToolStore } from '@/store/tool';
21
+ import { pluginSelectors } from '@/store/tool/selectors';
22
+
23
+ import ArgsInput from './ArgsInput';
24
+ import MCPTypeSelect from './MCPTypeSelect';
25
+ import { parseMcpInput } from './utils';
26
+
27
+ interface MCPManifestFormProps {
28
+ form: FormInstance;
29
+ isEditMode?: boolean;
30
+ }
31
+
32
+ // 定义预设的命令选项
33
+ const STDIO_COMMAND_OPTIONS: {
34
+ // 假设图标是 React 函数组件
35
+ color?: string;
36
+ icon?: FC<{ color?: string; size?: number }>;
37
+ value: string;
38
+ }[] = [
39
+ { color: '#CB3837', icon: SiNpm, value: 'npx' },
40
+ { color: '#CB3837', icon: SiNpm, value: 'npm' },
41
+ { color: '#F69220', icon: SiPnpm, value: 'pnpm' },
42
+ { color: '#F69220', icon: SiPnpm, value: 'pnpx' },
43
+ { color: '#339933', icon: SiNodedotjs, value: 'node' },
44
+ { color: '#efe2d2', icon: SiBun, value: 'bun' },
45
+ { color: '#efe2d2', icon: SiBun, value: 'bunx' },
46
+ { color: '#DE5FE9', icon: SiPython, value: 'uv' },
47
+ { color: '#3776AB', icon: SiPython, value: 'python' },
48
+ { color: '#2496ED', icon: SiDocker, value: 'docker' },
49
+ ];
50
+
51
+ const HTTP_URL_KEY = ['customParams', 'mcp', 'url'];
52
+ const STDIO_COMMAND = ['customParams', 'mcp', 'command'];
53
+ const STDIO_ARGS = ['customParams', 'mcp', 'args'];
54
+ const MCP_TYPE = ['customParams', 'mcp', 'type'];
55
+
56
+ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
57
+ const { t } = useTranslation('plugin');
58
+ const mcpType = Form.useWatch(MCP_TYPE, form);
59
+ const [manifest, setManifest] = useState<LobeChatPluginManifest>();
60
+ const pluginIds = useToolStore(pluginSelectors.storeAndInstallPluginsIdList);
61
+ const [pasteError, setPasteError] = useState<string | null>(null);
62
+ const [isTesting, setIsTesting] = useState(false);
63
+ const [connectionError, setConnectionError] = useState<string | null>(null);
64
+
65
+ const handleIdentifierChange = (e: ChangeEvent<HTMLInputElement>) => {
66
+ const value = e.target.value.trim();
67
+ setPasteError(null); // Clear previous errors on new input
68
+ setConnectionError(null); // Clear connection error on identifier change
69
+
70
+ const parseResult = parseMcpInput(value);
71
+
72
+ if (parseResult.status !== 'success') return;
73
+
74
+ const { identifier, mcpConfig } = parseResult;
75
+
76
+ if (!isDesktop && mcpConfig.type === 'stdio') {
77
+ return;
78
+ }
79
+
80
+ // Check for duplicate identifier (only in create mode)
81
+ if (!isEditMode && pluginIds.includes(identifier)) {
82
+ setPasteError(t('dev.meta.identifier.errorDuplicate'));
83
+ // Update form fields even if duplicate, so user sees the pasted values
84
+ form.setFieldsValue({
85
+ // Update identifier field
86
+ customParams: {
87
+ mcp: mcpConfig, // Spread the parsed config (includes type)
88
+ },
89
+ identifier: identifier,
90
+ });
91
+ // Trigger validation to show Form.Item error
92
+ form.validateFields(['identifier']);
93
+ return;
94
+ }
95
+
96
+ // No duplicate or in edit mode, fill the form
97
+ form.setFieldsValue({
98
+ customParams: { mcp: mcpConfig },
99
+ identifier: identifier,
100
+ });
101
+
102
+ // Clear potential old validation error on identifier
103
+ form.setFields([{ errors: [], name: 'identifier' }]);
104
+ };
105
+
106
+ const handleTestConnection = async () => {
107
+ setIsTesting(true);
108
+ setConnectionError(null);
109
+ setManifest(undefined); // Reset manifest before testing
110
+
111
+ // Manually trigger validation for fields needed for the test
112
+ let isValid = false;
113
+ try {
114
+ await form.validateFields([
115
+ ...(mcpType === 'http' ? [HTTP_URL_KEY] : [STDIO_COMMAND, STDIO_ARGS]),
116
+ ]);
117
+ isValid = true;
118
+ } catch {}
119
+
120
+ if (!isValid) {
121
+ setIsTesting(false);
122
+ return;
123
+ }
124
+
125
+ try {
126
+ const values = form.getFieldsValue();
127
+ const id = values.identifier;
128
+ const mcp = values.customParams?.mcp;
129
+
130
+ let data: LobeChatPluginManifest;
131
+
132
+ if (mcp.type === 'http') {
133
+ if (!mcp.url) throw new Error(t('dev.mcp.url.required'));
134
+ data = await mcpService.getStreamableMcpServerManifest(id, mcp.url);
135
+ } else if (mcp.type === 'stdio') {
136
+ if (!mcp.command) throw new Error(t('dev.mcp.command.required'));
137
+ if (!mcp.args) throw new Error(t('dev.mcp.args.required'));
138
+ data = await mcpService.getStdioMcpServerManifest(id, mcp.command, mcp.args);
139
+ } else {
140
+ throw new Error('Invalid MCP type'); // Internal error
141
+ }
142
+
143
+ setManifest(data);
144
+ // Optionally update form if manifest ID differs or to store the fetched manifest
145
+ // Be careful about overwriting user input if not desired
146
+ form.setFieldsValue({ manifest: data });
147
+ } catch (error) {
148
+ // Check if error is a validation error object (from validateFields)
149
+
150
+ // Handle API call errors or other errors
151
+ const err = error as Error; // Assuming PluginInstallError or similar structure
152
+ // Use the error message directly if it's a simple string error, otherwise try translation
153
+ // highlight-start
154
+ const errorMessage = t('error.testConnectionFailed', {
155
+ error: err.cause || err.message || t('unknownError'),
156
+ });
157
+ // highlight-end
158
+
159
+ setConnectionError(errorMessage);
160
+ } finally {
161
+ setIsTesting(false);
162
+ }
163
+ };
164
+
165
+ return (
166
+ <Form form={form} layout={'vertical'}>
167
+ <Flexbox>
168
+ <Form.Item
169
+ label={t('dev.mcp.type.title')}
170
+ name={['customParams', 'mcp', 'type']}
171
+ rules={[{ required: true }]}
172
+ >
173
+ <MCPTypeSelect />
174
+ </Form.Item>
175
+ {/* 仅在有粘贴相关错误时显示 Alert */}
176
+ {pasteError && (
177
+ <Alert message={pasteError} showIcon style={{ marginBottom: 16 }} type="error" />
178
+ )}
179
+ <Form.Item
180
+ extra={t('dev.mcp.identifier.desc')}
181
+ label={t('dev.mcp.identifier.label')}
182
+ name={'identifier'}
183
+ rules={[
184
+ { message: t('dev.mcp.identifier.required'), required: true },
185
+ {
186
+ message: t('dev.mcp.identifier.invalid'),
187
+ pattern: /^[\w-]+$/,
188
+ },
189
+ isEditMode
190
+ ? {}
191
+ : {
192
+ message: t('dev.meta.identifier.errorDuplicate'),
193
+ validator: async () => {
194
+ const id = form.getFieldValue('identifier');
195
+ if (!id) return true;
196
+ if (pluginIds.includes(id)) {
197
+ throw new Error('Duplicate');
198
+ }
199
+ },
200
+ },
201
+ ]}
202
+ >
203
+ <Input
204
+ onChange={handleIdentifierChange}
205
+ placeholder={t('dev.mcp.identifier.placeholder')}
206
+ />
207
+ </Form.Item>
208
+
209
+ {mcpType === 'http' && (
210
+ <Form.Item
211
+ extra={t('dev.mcp.url.desc')}
212
+ label={t('dev.mcp.url.label')}
213
+ name={HTTP_URL_KEY}
214
+ rules={[
215
+ { message: t('dev.mcp.url.required'), required: true },
216
+ { message: t('dev.mcp.url.invalid'), type: 'url' },
217
+ ]}
218
+ >
219
+ <Input placeholder="https://mcp.higress.ai/mcp-github/xxxxx" />
220
+ </Form.Item>
221
+ )}
222
+
223
+ {mcpType === 'stdio' && (
224
+ <>
225
+ <Form.Item
226
+ extra={t('dev.mcp.command.desc')}
227
+ label={t('dev.mcp.command.label')}
228
+ name={STDIO_COMMAND}
229
+ rules={[{ message: t('dev.mcp.command.required'), required: true }]}
230
+ >
231
+ <AutoComplete
232
+ options={STDIO_COMMAND_OPTIONS.map(({ value, icon: Icon, color }) => ({
233
+ label: (
234
+ <Flexbox align={'center'} gap={8} horizontal>
235
+ {Icon && <Icon color={color} size={16} />}
236
+ {value}
237
+ </Flexbox>
238
+ ),
239
+ value: value,
240
+ }))}
241
+ placeholder={t('dev.mcp.command.placeholder')}
242
+ />
243
+ </Form.Item>
244
+ <Form.Item
245
+ extra={t('dev.mcp.args.desc')}
246
+ label={t('dev.mcp.args.label')}
247
+ name={STDIO_ARGS}
248
+ rules={[{ message: t('dev.mcp.args.required'), required: true }]}
249
+ >
250
+ <ArgsInput placeholder={t('dev.mcp.args.placeholder')} />
251
+ </Form.Item>
252
+ </>
253
+ )}
254
+ <Form.Item extra={t('dev.mcp.testConnectionTip')}>
255
+ <Flexbox align={'center'} gap={8} horizontal>
256
+ <Button
257
+ loading={isTesting}
258
+ onClick={handleTestConnection}
259
+ type={!!mcpType ? 'primary' : undefined}
260
+ >
261
+ {t('dev.mcp.testConnection')}
262
+ </Button>
263
+ {manifest && !connectionError && !isTesting && (
264
+ <ManifestPreviewer manifest={manifest}>
265
+ <Flexbox>
266
+ <Button icon={<Icon icon={FileCode} />}>{t('dev.mcp.previewManifest')}</Button>
267
+ </Flexbox>
268
+ </ManifestPreviewer>
269
+ )}
270
+ </Flexbox>
271
+ </Form.Item>
272
+
273
+ {connectionError && (
274
+ <Alert
275
+ closable
276
+ message={connectionError}
277
+ onClose={() => setConnectionError(null)}
278
+ showIcon
279
+ style={{ marginBottom: 16 }}
280
+ type="error"
281
+ />
282
+ )}
283
+ <FormItem name={'manifest'} noStyle />
284
+ </Flexbox>
285
+ </Form>
286
+ );
287
+ };
288
+
289
+ export default MCPManifestForm;
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { McpParseErrorCode, parseMcpInput } from './utils';
4
+
5
+ describe('parseMcpInput', () => {
6
+ // Test Suite 1: Valid Nested mcpServers Structure
7
+ describe('Nested mcpServers Structure', () => {
8
+ it('should correctly parse valid stdio config', () => {
9
+ const input = JSON.stringify({
10
+ mcpServers: {
11
+ 'sequential-thinking': {
12
+ command: 'npx',
13
+ args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
14
+ },
15
+ },
16
+ });
17
+ const expected = {
18
+ status: 'success',
19
+ identifier: 'sequential-thinking',
20
+ mcpConfig: {
21
+ command: 'npx',
22
+ args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
23
+ type: 'stdio',
24
+ },
25
+ };
26
+ expect(parseMcpInput(input)).toEqual(expected);
27
+ });
28
+
29
+ it('should correctly parse valid http config', () => {
30
+ const input = JSON.stringify({
31
+ mcpServers: {
32
+ 'some-http-service': {
33
+ url: 'https://example.com/api',
34
+ },
35
+ },
36
+ });
37
+ const expected = {
38
+ status: 'success',
39
+ identifier: 'some-http-service',
40
+ mcpConfig: {
41
+ url: 'https://example.com/api',
42
+ type: 'http',
43
+ },
44
+ };
45
+ expect(parseMcpInput(input)).toEqual(expected);
46
+ });
47
+
48
+ it('should correctly parse valid http config with empty string identifier', () => {
49
+ const input = JSON.stringify({
50
+ mcpServers: {
51
+ '': {
52
+ url: 'https://router.mcp.so/mcp/mdvp27m9tl2bxs',
53
+ },
54
+ },
55
+ });
56
+ const expected = {
57
+ status: 'success',
58
+ identifier: '',
59
+ mcpConfig: {
60
+ url: 'https://router.mcp.so/mcp/mdvp27m9tl2bxs',
61
+ type: 'http',
62
+ },
63
+ };
64
+ expect(parseMcpInput(input)).toEqual(expected);
65
+ });
66
+
67
+ it('should return error for empty mcpServers object', () => {
68
+ const input = JSON.stringify({ mcpServers: {} });
69
+ const expected = {
70
+ status: 'error',
71
+ errorCode: McpParseErrorCode.EmptyMcpServers,
72
+ };
73
+ expect(parseMcpInput(input)).toEqual(expected);
74
+ });
75
+
76
+ it('should return error for invalid structure within mcpServers config', () => {
77
+ const input = JSON.stringify({
78
+ mcpServers: {
79
+ 'invalid-config': {}, // Missing command/args or url
80
+ },
81
+ });
82
+ const expected = {
83
+ status: 'error',
84
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
85
+ identifier: 'invalid-config',
86
+ };
87
+ expect(parseMcpInput(input)).toEqual(expected);
88
+ });
89
+
90
+ it('should return error if mcpConfig is not an object', () => {
91
+ const input = JSON.stringify({
92
+ mcpServers: {
93
+ 'not-an-object': 'hello',
94
+ },
95
+ });
96
+ const expected = {
97
+ status: 'error',
98
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
99
+ identifier: 'not-an-object',
100
+ };
101
+ expect(parseMcpInput(input)).toEqual(expected);
102
+ });
103
+
104
+ it('should return error if mcpConfig is null', () => {
105
+ const input = JSON.stringify({
106
+ mcpServers: {
107
+ 'is-null': null,
108
+ },
109
+ });
110
+ const expected = {
111
+ status: 'error',
112
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
113
+ identifier: 'is-null',
114
+ };
115
+ expect(parseMcpInput(input)).toEqual(expected);
116
+ });
117
+ });
118
+
119
+ // Test Suite 2: Valid Flat Structure (Top-level Identifier)
120
+ describe('Flat Structure (Top-level Identifier)', () => {
121
+ it('should correctly parse valid stdio config', () => {
122
+ const input = JSON.stringify({
123
+ 'flat-stdio-service': {
124
+ command: 'go',
125
+ args: ['run', 'main.go'],
126
+ },
127
+ });
128
+ const expected = {
129
+ status: 'success',
130
+ identifier: 'flat-stdio-service',
131
+ mcpConfig: {
132
+ command: 'go',
133
+ args: ['run', 'main.go'],
134
+ type: 'stdio',
135
+ },
136
+ };
137
+ expect(parseMcpInput(input)).toEqual(expected);
138
+ });
139
+
140
+ it('should correctly parse valid http config', () => {
141
+ const input = JSON.stringify({
142
+ 'mcp-wolframalpha': {
143
+ url: 'https://mcp.higress.ai/mcp-wolframalpha/abc',
144
+ },
145
+ });
146
+ const expected = {
147
+ status: 'success',
148
+ identifier: 'mcp-wolframalpha',
149
+ mcpConfig: {
150
+ url: 'https://mcp.higress.ai/mcp-wolframalpha/abc',
151
+ type: 'http',
152
+ },
153
+ };
154
+ expect(parseMcpInput(input)).toEqual(expected);
155
+ });
156
+
157
+ it('should return error for invalid structure within flat config', () => {
158
+ const input = JSON.stringify({
159
+ 'invalid-flat': {}, // Missing command/args or url
160
+ });
161
+ const expected = {
162
+ status: 'error',
163
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
164
+ identifier: 'invalid-flat',
165
+ };
166
+ expect(parseMcpInput(input)).toEqual(expected);
167
+ });
168
+
169
+ it('should return error if the value associated with the identifier is not an object', () => {
170
+ const input = JSON.stringify({
171
+ 'flat-not-object': 'just a string',
172
+ });
173
+ const expected = {
174
+ status: 'error',
175
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
176
+ identifier: 'flat-not-object',
177
+ };
178
+ expect(parseMcpInput(input)).toEqual(expected);
179
+ });
180
+
181
+ it('should return error if the value associated with the identifier is null', () => {
182
+ const input = JSON.stringify({
183
+ 'flat-is-null': null,
184
+ });
185
+ const expected = {
186
+ status: 'error',
187
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
188
+ identifier: 'flat-is-null',
189
+ };
190
+ expect(parseMcpInput(input)).toEqual(expected);
191
+ });
192
+
193
+ it('should return error for multiple top-level keys', () => {
194
+ const input = JSON.stringify({
195
+ key1: { url: 'url1' },
196
+ key2: { url: 'url2' },
197
+ });
198
+ const expected = {
199
+ status: 'error',
200
+ errorCode: McpParseErrorCode.InvalidJsonStructure, // Because it's not a single-key flat structure nor mcpServers/manifest
201
+ };
202
+ expect(parseMcpInput(input)).toEqual(expected);
203
+ });
204
+ });
205
+
206
+ // Test Suite 4: Invalid Inputs and Edge Cases
207
+ describe('Invalid Inputs and Edge Cases', () => {
208
+ it('should return noop for invalid JSON string', () => {
209
+ const input = 'this is not json';
210
+ const expected = { status: 'noop' };
211
+ expect(parseMcpInput(input)).toEqual(expected);
212
+ });
213
+
214
+ it('should return noop for empty string', () => {
215
+ const input = '';
216
+ const expected = { status: 'noop' };
217
+ expect(parseMcpInput(input)).toEqual(expected);
218
+ });
219
+
220
+ it('should return noop for null input', () => {
221
+ // @ts-ignore testing invalid input type
222
+ const input = null;
223
+ const expected = { status: 'noop' };
224
+ expect(parseMcpInput(input as any)).toEqual(expected);
225
+ });
226
+
227
+ it('should return noop for undefined input', () => {
228
+ // @ts-ignore testing invalid input type
229
+ const input = undefined;
230
+ const expected = { status: 'noop' };
231
+ expect(parseMcpInput(input as any)).toEqual(expected);
232
+ });
233
+
234
+ it('should return InvalidJsonStructure for empty JSON object', () => {
235
+ const input = JSON.stringify({});
236
+ // Empty object is considered an invalid structure because it doesn't match any expected format
237
+ const expected = {
238
+ status: 'error',
239
+ errorCode: McpParseErrorCode.InvalidJsonStructure,
240
+ };
241
+ expect(parseMcpInput(input)).toEqual(expected);
242
+ });
243
+
244
+ it('should return noop for JSON array', () => {
245
+ const input = JSON.stringify([]);
246
+ const expected = { status: 'noop' };
247
+ expect(parseMcpInput(input)).toEqual(expected);
248
+ });
249
+
250
+ it('should return noop for JSON primitive (string)', () => {
251
+ const input = JSON.stringify('just a string');
252
+ const expected = { status: 'noop' };
253
+ expect(parseMcpInput(input)).toEqual(expected);
254
+ });
255
+
256
+ it('should return noop for JSON primitive (number)', () => {
257
+ const input = JSON.stringify(123);
258
+ const expected = { status: 'noop' };
259
+ expect(parseMcpInput(input)).toEqual(expected);
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,151 @@
1
+ import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
2
+
3
+ import { safeParseJSON } from '@/utils/safeParseJSON';
4
+
5
+ // (McpConfig, McpServers, ParsedMcpInput 接口定义保持不变)
6
+ interface McpConfig {
7
+ args?: string[];
8
+ command?: string;
9
+ url?: string;
10
+ }
11
+
12
+ interface McpServers {
13
+ [key: string]: McpConfig;
14
+ }
15
+
16
+ interface ParsedMcpInput {
17
+ manifest?: LobeChatPluginManifest;
18
+ mcpServers?: McpServers;
19
+ }
20
+
21
+ // 移除 DuplicateIdentifier
22
+ export enum McpParseErrorCode {
23
+ EmptyMcpServers = 'EmptyMcpServers',
24
+ InvalidJsonStructure = 'InvalidJsonStructure',
25
+ InvalidMcpStructure = 'InvalidMcpStructure',
26
+ ManifestNotSupported = 'ManifestNotSupported',
27
+ }
28
+
29
+ // 移除 isDuplicate
30
+ interface ParseSuccessResult {
31
+ identifier: string;
32
+ mcpConfig: McpConfig & { type: 'stdio' | 'http' };
33
+ status: 'success';
34
+ }
35
+
36
+ interface ParseErrorResult {
37
+ errorCode: McpParseErrorCode;
38
+ // identifier 字段仍然可能有用,用于在结构错误时也能显示用户输入的 ID
39
+ identifier?: string;
40
+ status: 'error';
41
+ }
42
+
43
+ interface ParseNoOpResult {
44
+ status: 'noop';
45
+ }
46
+
47
+ export type ParseResult = ParseSuccessResult | ParseErrorResult | ParseNoOpResult;
48
+
49
+ export const parseMcpInput = (value: string): ParseResult => {
50
+ const parsedJson = safeParseJSON<ParsedMcpInput | McpServers>(value);
51
+
52
+ if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
53
+ // 1. Check for the nested "mcpServers" structure
54
+ if (
55
+ 'mcpServers' in parsedJson &&
56
+ typeof parsedJson.mcpServers === 'object' &&
57
+ parsedJson.mcpServers !== null
58
+ ) {
59
+ const mcpKeys = Object.keys(parsedJson.mcpServers);
60
+
61
+ if (mcpKeys.length > 0) {
62
+ const identifier = mcpKeys[0];
63
+ // @ts-expect-error type 不一样
64
+ const mcpConfig = parsedJson.mcpServers[identifier];
65
+
66
+ if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig)) {
67
+ let type: 'stdio' | 'http' | undefined;
68
+ let resultMcpConfig: McpConfig & { type?: 'stdio' | 'http' } = {};
69
+
70
+ if (mcpConfig.command && Array.isArray(mcpConfig.args)) {
71
+ type = 'stdio';
72
+ resultMcpConfig = { ...mcpConfig, type };
73
+ } else if (mcpConfig.url) {
74
+ type = 'http';
75
+ resultMcpConfig = { type, url: mcpConfig.url };
76
+ } else {
77
+ return {
78
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
79
+ identifier,
80
+ status: 'error',
81
+ };
82
+ }
83
+
84
+ return {
85
+ identifier,
86
+ mcpConfig: resultMcpConfig as McpConfig & { type: 'stdio' | 'http' },
87
+ status: 'success',
88
+ };
89
+ }
90
+ // mcpConfig is invalid or not an object
91
+ return {
92
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
93
+ identifier: identifier,
94
+ status: 'error',
95
+ };
96
+ } else {
97
+ // mcpServers object is empty
98
+ return { errorCode: McpParseErrorCode.EmptyMcpServers, status: 'error' };
99
+ }
100
+ }
101
+ // 3. Check for the flat structure (identifier as top-level key)
102
+ else {
103
+ const topLevelKeys = Object.keys(parsedJson);
104
+
105
+ // Allow exactly one top-level key which is the identifier
106
+ if (topLevelKeys.length === 1) {
107
+ const identifier = topLevelKeys[0];
108
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
109
+ const mcpConfig = (parsedJson as any)[identifier];
110
+
111
+ if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig)) {
112
+ let type: 'stdio' | 'http' | undefined;
113
+ let resultMcpConfig: McpConfig & { type?: 'stdio' | 'http' } = {};
114
+
115
+ // Explicitly check properties of mcpConfig
116
+ if (mcpConfig.command && Array.isArray(mcpConfig.args)) {
117
+ type = 'stdio';
118
+ resultMcpConfig = { ...mcpConfig, type };
119
+ } else if (mcpConfig.url) {
120
+ type = 'http';
121
+ // For the flat structure, ensure only 'url' is included for http type
122
+ resultMcpConfig = { type, url: mcpConfig.url };
123
+ } else {
124
+ // Invalid structure within the identifier's value
125
+ return {
126
+ errorCode: McpParseErrorCode.InvalidMcpStructure,
127
+ identifier, // We have the identifier here
128
+ status: 'error',
129
+ };
130
+ }
131
+
132
+ // Structure parsed successfully
133
+ return {
134
+ identifier,
135
+ mcpConfig: resultMcpConfig as McpConfig & { type: 'stdio' | 'http' },
136
+ status: 'success',
137
+ };
138
+ } else {
139
+ // The value associated with the single key is not a valid config object
140
+ return { errorCode: McpParseErrorCode.InvalidMcpStructure, identifier, status: 'error' };
141
+ }
142
+ } else {
143
+ // Neither mcpServers nor manifest, and not a single top-level key structure
144
+ return { errorCode: McpParseErrorCode.InvalidJsonStructure, status: 'error' };
145
+ }
146
+ }
147
+ }
148
+
149
+ // Input is not a valid JSON object or failed safeParseJSON
150
+ return { status: 'noop' }; // Or potentially InvalidJsonStructure if safeParse failed but wasn't null/undefined?
151
+ };