@lobehub/chat 1.82.1 → 1.82.3

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 (32) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/changelog/v1.json +18 -0
  3. package/locales/ar/plugin.json +25 -5
  4. package/locales/bg-BG/plugin.json +25 -5
  5. package/locales/de-DE/plugin.json +30 -5
  6. package/locales/en-US/plugin.json +30 -5
  7. package/locales/es-ES/plugin.json +25 -5
  8. package/locales/fa-IR/plugin.json +25 -5
  9. package/locales/fr-FR/plugin.json +25 -5
  10. package/locales/it-IT/plugin.json +25 -5
  11. package/locales/ja-JP/plugin.json +25 -5
  12. package/locales/ko-KR/plugin.json +30 -5
  13. package/locales/nl-NL/plugin.json +25 -5
  14. package/locales/pl-PL/plugin.json +30 -5
  15. package/locales/pt-BR/plugin.json +25 -5
  16. package/locales/ru-RU/plugin.json +25 -5
  17. package/locales/tr-TR/plugin.json +25 -5
  18. package/locales/vi-VN/plugin.json +30 -5
  19. package/locales/zh-CN/plugin.json +23 -8
  20. package/locales/zh-TW/plugin.json +25 -5
  21. package/package.json +1 -1
  22. package/src/config/aiModels/openrouter.ts +6 -6
  23. package/src/features/PluginDevModal/MCPManifestForm/ArgsInput.tsx +1 -1
  24. package/src/features/PluginDevModal/MCPManifestForm/index.tsx +160 -97
  25. package/src/features/PluginDevModal/MCPManifestForm/utils.test.ts +262 -0
  26. package/src/features/PluginDevModal/MCPManifestForm/utils.ts +151 -0
  27. package/src/features/PluginDevModal/index.tsx +31 -22
  28. package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.ts +1 -1
  29. package/src/libs/agent-runtime/utils/streams/openai.test.ts +160 -0
  30. package/src/libs/agent-runtime/utils/streams/openai.ts +1 -1
  31. package/src/locales/default/plugin.ts +16 -3
  32. package/src/server/services/mcp/index.ts +7 -0
@@ -7,21 +7,22 @@ import {
7
7
  SiPython,
8
8
  } from '@icons-pack/react-simple-icons';
9
9
  import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
10
- import { ActionIcon, FormItem } from '@lobehub/ui';
11
- import { AutoComplete, Form, FormInstance, Input } from 'antd';
12
- import { FileCode, RotateCwIcon } from 'lucide-react';
13
- import { FC, useState } from 'react';
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
14
  import { useTranslation } from 'react-i18next';
15
15
  import { Flexbox } from 'react-layout-kit';
16
16
 
17
17
  import ManifestPreviewer from '@/components/ManifestPreviewer';
18
+ import { isDesktop } from '@/const/version';
18
19
  import { mcpService } from '@/services/mcp';
19
20
  import { useToolStore } from '@/store/tool';
20
21
  import { pluginSelectors } from '@/store/tool/selectors';
21
- import { PluginInstallError } from '@/types/tool/plugin';
22
22
 
23
23
  import ArgsInput from './ArgsInput';
24
24
  import MCPTypeSelect from './MCPTypeSelect';
25
+ import { parseMcpInput } from './utils';
25
26
 
26
27
  interface MCPManifestFormProps {
27
28
  form: FormInstance;
@@ -47,15 +48,120 @@ const STDIO_COMMAND_OPTIONS: {
47
48
  { color: '#2496ED', icon: SiDocker, value: 'docker' },
48
49
  ];
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
+
50
56
  const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
51
57
  const { t } = useTranslation('plugin');
52
- const mcpType = Form.useWatch(['customParams', 'mcp', 'type'], form);
58
+ const mcpType = Form.useWatch(MCP_TYPE, form);
53
59
  const [manifest, setManifest] = useState<LobeChatPluginManifest>();
54
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
+ };
55
164
 
56
- const HTTP_URL_KEY = ['customParams', 'mcp', 'url'];
57
- const STDIO_COMMAND = ['customParams', 'mcp', 'command'];
58
- const STDIO_ARGS = ['customParams', 'mcp', 'args'];
59
165
  return (
60
166
  <Form form={form} layout={'vertical'}>
61
167
  <Flexbox>
@@ -66,12 +172,16 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
66
172
  >
67
173
  <MCPTypeSelect />
68
174
  </Form.Item>
175
+ {/* 仅在有粘贴相关错误时显示 Alert */}
176
+ {pasteError && (
177
+ <Alert message={pasteError} showIcon style={{ marginBottom: 16 }} type="error" />
178
+ )}
69
179
  <Form.Item
70
180
  extra={t('dev.mcp.identifier.desc')}
71
181
  label={t('dev.mcp.identifier.label')}
72
182
  name={'identifier'}
73
183
  rules={[
74
- { required: true },
184
+ { message: t('dev.mcp.identifier.required'), required: true },
75
185
  {
76
186
  message: t('dev.mcp.identifier.invalid'),
77
187
  pattern: /^[\w-]+$/,
@@ -90,63 +200,23 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
90
200
  },
91
201
  ]}
92
202
  >
93
- <Input placeholder={t('dev.mcp.identifier.placeholder')} />
203
+ <Input
204
+ onChange={handleIdentifierChange}
205
+ placeholder={t('dev.mcp.identifier.placeholder')}
206
+ />
94
207
  </Form.Item>
95
208
 
96
209
  {mcpType === 'http' && (
97
210
  <Form.Item
98
- extra={
99
- <Flexbox horizontal justify={'space-between'} style={{ marginTop: 8 }}>
100
- {t('dev.mcp.url.desc')}
101
- {manifest && (
102
- <ManifestPreviewer manifest={manifest}>
103
- <ActionIcon
104
- icon={FileCode}
105
- size={'small'}
106
- title={t('dev.meta.manifest.preview')}
107
- />
108
- </ManifestPreviewer>
109
- )}
110
- </Flexbox>
111
- }
112
- hasFeedback
211
+ extra={t('dev.mcp.url.desc')}
113
212
  label={t('dev.mcp.url.label')}
114
213
  name={HTTP_URL_KEY}
115
214
  rules={[
116
- { required: true },
117
- { type: 'url' },
118
- {
119
- validator: async (_, value) => {
120
- if (!value) return true;
121
- try {
122
- const data = await mcpService.getStreamableMcpServerManifest(
123
- form.getFieldValue('identifier'),
124
- value,
125
- );
126
- setManifest(data);
127
- form.setFieldsValue({ identifier: data.identifier, manifest: data });
128
- } catch (error) {
129
- const err = error as PluginInstallError;
130
- throw t(`error.${err.message}`, { error: err.cause! });
131
- }
132
- },
133
- },
215
+ { message: t('dev.mcp.url.required'), required: true },
216
+ { message: t('dev.mcp.url.invalid'), type: 'url' },
134
217
  ]}
135
218
  >
136
- <Input
137
- placeholder="https://mcp.higress.ai/mcp-github/xxxxx"
138
- suffix={
139
- <ActionIcon
140
- icon={RotateCwIcon}
141
- onClick={(e) => {
142
- e.stopPropagation();
143
- form.validateFields([HTTP_URL_KEY]);
144
- }}
145
- size={'small'}
146
- title={t('dev.meta.manifest.refresh')}
147
- />
148
- }
149
- />
219
+ <Input placeholder="https://mcp.higress.ai/mcp-github/xxxxx" />
150
220
  </Form.Item>
151
221
  )}
152
222
 
@@ -156,7 +226,7 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
156
226
  extra={t('dev.mcp.command.desc')}
157
227
  label={t('dev.mcp.command.label')}
158
228
  name={STDIO_COMMAND}
159
- rules={[{ required: true }]}
229
+ rules={[{ message: t('dev.mcp.command.required'), required: true }]}
160
230
  >
161
231
  <AutoComplete
162
232
  options={STDIO_COMMAND_OPTIONS.map(({ value, icon: Icon, color }) => ({
@@ -173,50 +243,43 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
173
243
  </Form.Item>
174
244
  <Form.Item
175
245
  extra={t('dev.mcp.args.desc')}
176
- hasFeedback
177
246
  label={t('dev.mcp.args.label')}
178
247
  name={STDIO_ARGS}
179
- rules={[
180
- { required: true },
181
- {
182
- validator: async (_, value) => {
183
- if (!value) return true;
184
- const name = form.getFieldValue('identifier');
185
-
186
- if (!name) throw new Error('Please input mcp server name');
187
- try {
188
- const data = await mcpService.getStdioMcpServerManifest(
189
- name,
190
- form.getFieldValue(STDIO_COMMAND),
191
- value,
192
- );
193
- setManifest(data);
194
- form.setFieldsValue({ identifier: data.identifier, manifest: data });
195
- } catch (error) {
196
- const err = error as PluginInstallError;
197
- throw t(`error.${err.message}`, { error: err.cause! });
198
- }
199
- },
200
- },
201
- ]}
248
+ rules={[{ message: t('dev.mcp.args.required'), required: true }]}
202
249
  >
203
- <ArgsInput
204
- placeholder={t('dev.mcp.args.placeholder')}
205
- suffix={
206
- <ActionIcon
207
- icon={RotateCwIcon}
208
- onClick={(e) => {
209
- e.stopPropagation();
210
- form.validateFields([STDIO_ARGS]);
211
- }}
212
- size={'small'}
213
- title={t('dev.meta.manifest.refresh')}
214
- />
215
- }
216
- />
250
+ <ArgsInput placeholder={t('dev.mcp.args.placeholder')} />
217
251
  </Form.Item>
218
252
  </>
219
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
+ )}
220
283
  <FormItem name={'manifest'} noStyle />
221
284
  </Flexbox>
222
285
  </Form>
@@ -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
+ });