@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.
- package/.cursor/rules/desktop-local-tools-implement.mdc +80 -0
- package/.env.desktop +2 -1
- package/.github/scripts/pr-comment.js +4 -9
- package/CHANGELOG.md +51 -0
- package/changelog/v1.json +18 -0
- package/locales/ar/electron.json +38 -2
- package/locales/ar/plugin.json +51 -31
- package/locales/bg-BG/electron.json +38 -2
- package/locales/bg-BG/plugin.json +51 -31
- package/locales/de-DE/electron.json +38 -2
- package/locales/de-DE/plugin.json +29 -9
- package/locales/en-US/electron.json +38 -2
- package/locales/en-US/plugin.json +29 -9
- package/locales/es-ES/electron.json +38 -2
- package/locales/es-ES/plugin.json +51 -31
- package/locales/fa-IR/electron.json +38 -2
- package/locales/fa-IR/plugin.json +51 -31
- package/locales/fr-FR/electron.json +38 -2
- package/locales/fr-FR/plugin.json +51 -31
- package/locales/it-IT/electron.json +38 -2
- package/locales/it-IT/plugin.json +51 -31
- package/locales/ja-JP/electron.json +38 -2
- package/locales/ja-JP/plugin.json +51 -31
- package/locales/ko-KR/electron.json +38 -2
- package/locales/ko-KR/plugin.json +29 -9
- package/locales/nl-NL/electron.json +38 -2
- package/locales/nl-NL/plugin.json +51 -31
- package/locales/pl-PL/electron.json +38 -2
- package/locales/pl-PL/plugin.json +29 -9
- package/locales/pt-BR/electron.json +38 -2
- package/locales/pt-BR/plugin.json +51 -31
- package/locales/ru-RU/electron.json +38 -2
- package/locales/ru-RU/plugin.json +51 -31
- package/locales/tr-TR/electron.json +38 -2
- package/locales/tr-TR/plugin.json +51 -31
- package/locales/vi-VN/electron.json +38 -2
- package/locales/vi-VN/plugin.json +29 -9
- package/locales/zh-CN/electron.json +38 -2
- package/locales/zh-CN/plugin.json +30 -10
- package/locales/zh-TW/electron.json +38 -2
- package/locales/zh-TW/plugin.json +51 -31
- package/package.json +1 -1
- package/packages/electron-client-ipc/src/events/update.ts +3 -3
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Mode.tsx +222 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Option.tsx +104 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Sync.tsx +42 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/Waiting.tsx +203 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/Connection/index.tsx +57 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/UpdateModal.tsx +242 -0
- package/src/app/[variants]/(main)/_layout/Desktop/ElectronTitlebar/UpdateNotification.tsx +193 -0
- package/src/app/[variants]/(main)/_layout/Desktop/{Titlebar.tsx → ElectronTitlebar/index.tsx} +15 -1
- package/src/app/[variants]/(main)/_layout/Desktop/SideBar/BottomActions.tsx +3 -2
- package/src/app/[variants]/(main)/_layout/Desktop/index.tsx +1 -1
- package/src/app/[variants]/layout.tsx +2 -1
- package/src/config/aiModels/openrouter.ts +6 -6
- package/src/features/Conversation/components/MarkdownElements/LocalFile/Render/LocalFile.tsx +65 -0
- package/src/features/Conversation/components/MarkdownElements/LocalFile/Render/index.tsx +29 -0
- package/src/features/Conversation/components/MarkdownElements/LocalFile/index.ts +16 -0
- package/src/features/Conversation/components/MarkdownElements/index.ts +7 -1
- package/src/features/Conversation/components/MarkdownElements/remarkPlugins/__snapshots__/createRemarkSelfClosingTagPlugin.test.ts.snap +260 -0
- package/src/features/Conversation/components/MarkdownElements/remarkPlugins/createRemarkSelfClosingTagPlugin.test.ts +204 -0
- package/src/features/Conversation/components/MarkdownElements/remarkPlugins/createRemarkSelfClosingTagPlugin.ts +133 -0
- package/src/features/Conversation/components/MarkdownElements/type.ts +5 -1
- package/src/features/PluginDevModal/MCPManifestForm/ArgsInput.tsx +20 -0
- package/src/features/PluginDevModal/MCPManifestForm/MCPTypeSelect.tsx +176 -0
- package/src/features/PluginDevModal/MCPManifestForm/index.tsx +289 -0
- package/src/features/PluginDevModal/MCPManifestForm/utils.test.ts +262 -0
- package/src/features/PluginDevModal/MCPManifestForm/utils.ts +151 -0
- package/src/features/PluginDevModal/index.tsx +31 -22
- package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.ts +1 -1
- package/src/libs/mcp/__tests__/__snapshots__/index.test.ts.snap +0 -56
- package/src/locales/default/electron.ts +38 -2
- package/src/locales/default/plugin.ts +28 -8
- package/src/server/modules/ElectronIPCClient/index.ts +36 -0
- package/src/server/routers/lambda/session.ts +2 -6
- package/src/server/routers/tools/mcp.ts +6 -0
- package/src/server/services/file/impls/index.ts +9 -1
- package/src/server/services/file/impls/local.test.ts +299 -0
- package/src/server/services/file/impls/local.ts +183 -0
- package/src/server/services/mcp/index.ts +26 -0
- package/src/services/aiModel/index.ts +5 -1
- package/src/services/aiProvider/index.ts +5 -1
- package/src/services/electron/autoUpdate.ts +4 -0
- package/src/services/file/index.ts +5 -1
- package/src/services/mcp.ts +13 -2
- package/src/services/message/index.ts +5 -1
- package/src/services/plugin/index.ts +5 -1
- package/src/services/session/index.ts +5 -1
- package/src/services/tableViewer/desktop.ts +15 -0
- package/src/services/tableViewer/index.ts +4 -1
- package/src/services/thread/index.ts +5 -1
- package/src/services/topic/index.ts +5 -1
- package/src/services/user/index.ts +5 -1
- package/src/store/electron/actions/app.ts +59 -0
- package/src/store/electron/actions/sync.ts +5 -1
- package/src/store/electron/initialState.ts +3 -1
- package/src/store/electron/store.ts +6 -1
- package/src/store/tool/slices/customPlugin/action.ts +16 -4
- package/src/utils/client/GlobalAgentContextManager.ts +85 -0
- package/src/utils/promptTemplate.test.ts +78 -0
- package/src/utils/promptTemplate.ts +17 -0
- 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
|
+
};
|