dsh-mcp 1.5.0 → 1.7.0
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/CHANGELOG.en.md +17 -0
- package/CHANGELOG.md +17 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +121 -111
- package/lib/mcp-client.js +161 -26
- package/package.json +3 -2
- package/src/client/ServersJsonEditor.tsx +56 -42
- package/src/client/locales.ts +2 -2
package/lib/mcp-client.js
CHANGED
|
@@ -6,7 +6,9 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
6
6
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
7
|
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
10
|
import { z as z$1 } from "zod";
|
|
11
|
+
import { isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
|
|
10
12
|
import { assertSupportedJsonSchema } from "@deepseek-ai/dsh-tools";
|
|
11
13
|
//#region lib/types/transport.js
|
|
12
14
|
/**
|
|
@@ -74,6 +76,15 @@ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
|
74
76
|
const HASH_LENGTH = 12;
|
|
75
77
|
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
|
76
78
|
const RawCallToolResultSchema = z$1.record(z$1.string(), z$1.unknown());
|
|
79
|
+
/** Raster formats supported by the durable attachment vocabulary. */
|
|
80
|
+
const IMAGE_MEDIA_TYPES = [
|
|
81
|
+
"image/png",
|
|
82
|
+
"image/jpeg",
|
|
83
|
+
"image/webp",
|
|
84
|
+
"image/gif"
|
|
85
|
+
];
|
|
86
|
+
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
87
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
77
88
|
/** List without mutating the SDK's per-page output-validator cache. */
|
|
78
89
|
function listToolsUncached(client, cursor) {
|
|
79
90
|
return client.request({
|
|
@@ -147,13 +158,7 @@ async function syncTools(client, ctx, opts, previous) {
|
|
|
147
158
|
for (const tool of response.tools) {
|
|
148
159
|
const publicName = publicToolName(opts.serverName, tool.name);
|
|
149
160
|
if (definitions.has(publicName)) throw new Error(`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
|
|
150
|
-
definitions.set(publicName,
|
|
151
|
-
name: publicName,
|
|
152
|
-
description: tool.description ?? "",
|
|
153
|
-
parameters: tool.inputSchema,
|
|
154
|
-
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
|
|
155
|
-
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === "required", opts)
|
|
156
|
-
});
|
|
161
|
+
definitions.set(publicName, createDefinition(client, ctx, publicName, tool.name, tool.description ?? "", tool.inputSchema, supportedOutputSchema(tool.outputSchema), tool.execution?.taskSupport === "required", opts));
|
|
157
162
|
}
|
|
158
163
|
cursor = response.nextCursor;
|
|
159
164
|
} while (cursor);
|
|
@@ -213,6 +218,26 @@ function supportedOutputSchema(candidate) {
|
|
|
213
218
|
return;
|
|
214
219
|
}
|
|
215
220
|
}
|
|
221
|
+
/** Build one tool definition with execution-local rich image projections. */
|
|
222
|
+
function createDefinition(client, ctx, publicName, rawName, description, parameters, structuredSchema, taskRequired, opts) {
|
|
223
|
+
const projections = /* @__PURE__ */ new WeakMap();
|
|
224
|
+
return {
|
|
225
|
+
name: publicName,
|
|
226
|
+
description,
|
|
227
|
+
parameters,
|
|
228
|
+
output: createOutput(rawName, structuredSchema),
|
|
229
|
+
execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
|
|
230
|
+
finalizeContent(exec, result) {
|
|
231
|
+
const projection = projections.get(exec);
|
|
232
|
+
if (projection === void 0) return void 0;
|
|
233
|
+
projections.delete(exec);
|
|
234
|
+
if (result.isError) return void 0;
|
|
235
|
+
if (!isDeepStrictEqual(result.value, projection.value)) return void 0;
|
|
236
|
+
if (!isDeepStrictEqual(result.content, projection.fallback)) return void 0;
|
|
237
|
+
return projection.content;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
216
241
|
/** Build the canonical result schema and existing Native text projection. */
|
|
217
242
|
function createOutput(rawName, structuredSchema) {
|
|
218
243
|
return {
|
|
@@ -246,7 +271,7 @@ function createOutput(rawName, structuredSchema) {
|
|
|
246
271
|
* When the MCP server returns `isError: true`, the executor throws so that
|
|
247
272
|
* the ToolRuntime's catch path produces an `isError` result for the model.
|
|
248
273
|
*/
|
|
249
|
-
function createExecutor(client, rawName, taskRequired, opts) {
|
|
274
|
+
function createExecutor(client, ctx, rawName, taskRequired, opts, projections) {
|
|
250
275
|
return async (args, exec) => {
|
|
251
276
|
if (taskRequired) throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
|
|
252
277
|
const result = await callToolUncached(client, rawName, typeof args === "object" && args !== null ? args : {}, exec, opts);
|
|
@@ -265,10 +290,23 @@ function createExecutor(client, rawName, taskRequired, opts) {
|
|
|
265
290
|
const content = result.content;
|
|
266
291
|
const text = extractText(content, rawName);
|
|
267
292
|
if (result.isError === true) throw new Error(text);
|
|
268
|
-
|
|
293
|
+
const value = {
|
|
269
294
|
content,
|
|
270
295
|
...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
|
|
271
296
|
};
|
|
297
|
+
if (containsImage(content)) {
|
|
298
|
+
const fallback = [{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: extractText(content, rawName)
|
|
301
|
+
}];
|
|
302
|
+
const projected = await prepareImageProjection(ctx, exec, content, rawName);
|
|
303
|
+
projections.set(exec, {
|
|
304
|
+
value,
|
|
305
|
+
fallback,
|
|
306
|
+
content: projected
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return value;
|
|
272
310
|
};
|
|
273
311
|
}
|
|
274
312
|
/**
|
|
@@ -279,32 +317,129 @@ function createExecutor(client, rawName, taskRequired, opts) {
|
|
|
279
317
|
* Defensive: fields that the MCP spec declares required (mimeType, text) are
|
|
280
318
|
* guarded with fallbacks because this is a network trust boundary.
|
|
281
319
|
*/
|
|
320
|
+
function containsImage(content) {
|
|
321
|
+
return content.some((value) => isRecord(value) && value.type === "image");
|
|
322
|
+
}
|
|
323
|
+
function isRecord(value) {
|
|
324
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
325
|
+
}
|
|
326
|
+
function isImageMediaType(value) {
|
|
327
|
+
return IMAGE_MEDIA_TYPES.includes(value);
|
|
328
|
+
}
|
|
329
|
+
function decodedByteLength(base64) {
|
|
330
|
+
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
331
|
+
return base64.length / 4 * 3 - padding;
|
|
332
|
+
}
|
|
333
|
+
function imageDiagnostic(reason) {
|
|
334
|
+
return `[image unavailable: ${reason}; image bytes were not added to text context]`;
|
|
335
|
+
}
|
|
336
|
+
function preflightImages(content, limits) {
|
|
337
|
+
const images = [];
|
|
338
|
+
let totalBytes = 0;
|
|
339
|
+
for (const [index, value] of content.entries()) {
|
|
340
|
+
if (!isRecord(value) || value.type !== "image") continue;
|
|
341
|
+
if (value.mimeType === void 0 || !isImageMediaType(value.mimeType) || !limits.mediaTypes.includes(value.mimeType)) return { reason: "image type is not accepted" };
|
|
342
|
+
if (value.data === void 0 || !CANONICAL_BASE64.test(value.data)) return { reason: "invalid image data" };
|
|
343
|
+
const bytes = decodedByteLength(value.data);
|
|
344
|
+
if (bytes > limits.maxImageBytes) return { reason: "image exceeds the active size limit" };
|
|
345
|
+
totalBytes += bytes;
|
|
346
|
+
if (totalBytes > limits.maxMessageImageBytes) return { reason: "image batch exceeds the active size limit" };
|
|
347
|
+
images.push({ index, block: value });
|
|
348
|
+
}
|
|
349
|
+
if (images.length > limits.maxImagesPerMessage) return { reason: "image batch exceeds the active count limit" };
|
|
350
|
+
return { images };
|
|
351
|
+
}
|
|
352
|
+
function decodeImage(block) {
|
|
353
|
+
const data = Buffer.from(block.data, "base64");
|
|
354
|
+
if (data.toString("base64") !== block.data) throw new Error("invalid image data");
|
|
355
|
+
return { data, mediaType: block.mimeType };
|
|
356
|
+
}
|
|
357
|
+
async function resolveImageAdmission(ctx, exec) {
|
|
358
|
+
const attachments = ctx.get("attachments");
|
|
359
|
+
if (attachments === void 0 || attachments.imageLimits === void 0) throw new Error("attachment store unavailable");
|
|
360
|
+
const routed = exec.agent?.session.requestHeader()?.config;
|
|
361
|
+
const provider = routed?.provider ?? exec.agent?.options.provider;
|
|
362
|
+
const model = routed?.model ?? exec.agent?.options.model;
|
|
363
|
+
const llm = ctx.get("llm");
|
|
364
|
+
if (provider === void 0 || model === void 0 || llm === void 0) throw new Error("model route unavailable");
|
|
365
|
+
let info;
|
|
366
|
+
try {
|
|
367
|
+
info = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error("model route unavailable");
|
|
370
|
+
}
|
|
371
|
+
if (info.inputModalities === void 0 || !info.inputModalities.includes("image")) throw new Error("model route is not image-capable");
|
|
372
|
+
if (exec.signal.aborted) throw new Error("tool call canceled");
|
|
373
|
+
return attachments;
|
|
374
|
+
}
|
|
375
|
+
function admissionDiagnostic(error) {
|
|
376
|
+
switch (error?.code) {
|
|
377
|
+
case "TOO_MANY_IMAGES": return "image batch exceeds the active count limit";
|
|
378
|
+
case "IMAGES_TOO_LARGE": return "image batch exceeds the active size limit";
|
|
379
|
+
case "IMAGE_TOO_LARGE": return "image exceeds the active size limit";
|
|
380
|
+
case "UNSUPPORTED_IMAGE_TYPE": return "image type is not accepted";
|
|
381
|
+
case "IMAGE_TYPE_MISMATCH": return "declared image type does not match image bytes";
|
|
382
|
+
case "INVALID_IMAGE_BASE64": return "invalid image Base64";
|
|
383
|
+
case "INVALID_IMAGE": return "malformed or unsupported image data";
|
|
384
|
+
case "IMAGE_TOO_MANY_PIXELS": return "image exceeds the decoded-pixel limit";
|
|
385
|
+
case "IMAGE_DIMENSION_TOO_LARGE": return "image exceeds the maximum side dimension";
|
|
386
|
+
default: return isImageAdmissionError(error) ? "image admission failed" : "image storage unavailable";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function prepareImageProjection(ctx, exec, content, toolName) {
|
|
390
|
+
let attachments;
|
|
391
|
+
try {
|
|
392
|
+
attachments = await resolveImageAdmission(ctx, exec);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const reason = error?.message === "model route is not image-capable" ? "model route is not image-capable" : error?.message === "tool call canceled" ? "tool call canceled" : error?.message === "attachment store unavailable" ? "attachment store unavailable" : "model route unavailable";
|
|
395
|
+
return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(reason) }));
|
|
396
|
+
}
|
|
397
|
+
const preflight = preflightImages(content, attachments.imageLimits);
|
|
398
|
+
if (preflight.reason !== void 0) return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(preflight.reason) }));
|
|
399
|
+
let decoded;
|
|
400
|
+
try {
|
|
401
|
+
decoded = preflight.images.map(({ block }) => decodeImage(block));
|
|
402
|
+
} catch {
|
|
403
|
+
return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic("invalid image data") }));
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const refs = await attachments.saveImages(decoded);
|
|
407
|
+
const byIndex = new Map(preflight.images.map(({ index }, offset) => [index, refs[offset]]));
|
|
408
|
+
return projectContent(content, toolName, (_block, index) => ({ type: "image", attachment: byIndex.get(index) }));
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(admissionDiagnostic(error)) }));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
282
413
|
function extractText(mcpContent, toolName) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
414
|
+
return projectContent(mcpContent, toolName).map((block) => block.text).join("\n");
|
|
415
|
+
}
|
|
416
|
+
function projectContent(mcpContent, toolName, image = () => ({ type: "text", text: imageDiagnostic("this result was not admitted to model image context") })) {
|
|
417
|
+
const projected = [];
|
|
418
|
+
const text = [];
|
|
419
|
+
const flushText = () => {
|
|
420
|
+
if (text.length === 0) return;
|
|
421
|
+
projected.push({ type: "text", text: text.splice(0).join("\n") });
|
|
422
|
+
};
|
|
423
|
+
for (const [index, value] of mcpContent.entries()) {
|
|
424
|
+
if (!isRecord(value)) {
|
|
425
|
+
text.push("[unsupported MCP content block: expected an object]");
|
|
287
426
|
continue;
|
|
288
427
|
}
|
|
289
428
|
const block = value;
|
|
290
429
|
switch (block.type) {
|
|
291
|
-
case "text":
|
|
292
|
-
|
|
293
|
-
break;
|
|
294
|
-
case "image":
|
|
295
|
-
parts.push(`[image: ${block.mimeType ?? "unknown"}, content discarded]`);
|
|
296
|
-
break;
|
|
297
|
-
case "audio":
|
|
298
|
-
parts.push(`[audio: ${block.mimeType ?? "unknown"}, content discarded]`);
|
|
299
|
-
break;
|
|
300
|
-
case "resource":
|
|
430
|
+
case "text": if (block.text !== void 0) text.push(block.text); break;
|
|
431
|
+
case "image": flushText(); projected.push(image(block, index)); break;
|
|
301
432
|
case "resource_link":
|
|
302
|
-
|
|
433
|
+
if (block.name === void 0 || block.uri === void 0) text.push("[resource link unavailable: the MCP block is missing its name or URI]");
|
|
434
|
+
else text.push(`Resource link: ${block.name} (${block.uri})`);
|
|
303
435
|
break;
|
|
304
|
-
|
|
436
|
+
case "audio": text.push("[audio result unsupported; audio bytes were not added to text context]"); break;
|
|
437
|
+
case "resource": text.push("[embedded resource unsupported; resource bytes were not added to text context]"); break;
|
|
438
|
+
default: text.push(`[unsupported MCP content type: ${block.type}]`);
|
|
305
439
|
}
|
|
306
440
|
}
|
|
307
|
-
|
|
441
|
+
flushText();
|
|
442
|
+
return projected.length > 0 ? projected : [{ type: "text", text: `(${toolName} returned no model-visible content)` }];
|
|
308
443
|
}
|
|
309
444
|
//#endregion
|
|
310
445
|
//#region lib/types/connection.js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "MCP 管理界面 + tool search:可视化配置/启停/刷新 MCP 服务器与工具勾选,按需检索(tool search)热注入;工具列表稳定命中缓存、不撑爆上下文。DeepSeek Harness plugin: MCP management UI + tool search — stable tool list, cache-friendly, no context bloat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "node scripts/build.mjs"
|
|
24
|
+
"build": "node scripts/build.mjs",
|
|
25
|
+
"test": "node --test test/mcp-image-projection.test.mjs"
|
|
25
26
|
},
|
|
26
27
|
"author": "Arvin.qi <arvin.qi@qq.com>",
|
|
27
28
|
"license": "MIT",
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JSON server-config editor: view and edit the whole MCP server list as one
|
|
3
|
-
* JSON document
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* key-value JSON document (server name → config). Applying it replaces the
|
|
4
|
+
* whole list — listed servers are created or updated, existing servers
|
|
5
|
+
* absent from the document are removed. Secret env values are never
|
|
6
|
+
* exported (a `configured` flag marks them) and a blank secret value keeps
|
|
7
|
+
* the stored one.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* {
|
|
11
|
-
* "
|
|
12
|
-
* "transport": "streamable-http",
|
|
13
|
-
* "enabled": true,
|
|
9
|
+
* {
|
|
10
|
+
* "feishu-mcp": {
|
|
11
|
+
* "type": "streamable_http",
|
|
14
12
|
* "url": "https://.../mcp",
|
|
15
13
|
* "headers": { "Authorization": "Bearer ..." },
|
|
16
|
-
* "
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* "disabled": false
|
|
15
|
+
* },
|
|
16
|
+
* "local": {
|
|
17
|
+
* "type": "stdio",
|
|
18
|
+
* "command": "npx",
|
|
19
|
+
* "args": ["-y", "some-server"],
|
|
20
|
+
* "disabled": false
|
|
19
21
|
* }
|
|
20
|
-
*
|
|
22
|
+
* }
|
|
21
23
|
* @module dsh-mcp/client/ServersJsonEditor
|
|
22
24
|
*/
|
|
23
25
|
|
|
@@ -48,13 +50,13 @@ export interface ServersJsonEditorProps {
|
|
|
48
50
|
readonly onApplied: () => void
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
/** Serialize the current server list into the JSON document text. */
|
|
53
|
+
/** Serialize the current server list into the JSON document text (key-value). */
|
|
52
54
|
export function serversToJsonText(servers: readonly McpServerView[]): string {
|
|
53
|
-
const document
|
|
55
|
+
const document: Record<string, Record<string, unknown>> = {}
|
|
56
|
+
for (const server of servers) {
|
|
54
57
|
const entry: Record<string, unknown> = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
enabled: server.enabled,
|
|
58
|
+
type: server.transport === 'stdio' ? 'stdio' : 'streamable_http',
|
|
59
|
+
disabled: !server.enabled,
|
|
58
60
|
}
|
|
59
61
|
if (server.transport === 'stdio') {
|
|
60
62
|
entry.command = server.command
|
|
@@ -71,8 +73,8 @@ export function serversToJsonText(servers: readonly McpServerView[]): string {
|
|
|
71
73
|
entry.env = server.env.map(row => row.secret
|
|
72
74
|
? { name: row.name, secret: true, configured: row.configured }
|
|
73
75
|
: { name: row.name, secret: false, ...(row.value !== undefined && row.value.length > 0 ? { value: row.value } : {}) })
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
+
document[server.serverName] = entry
|
|
77
|
+
}
|
|
76
78
|
return `${JSON.stringify(document, null, 2)}\n`
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -84,40 +86,38 @@ export function parseServersJson(text: string): { ok: true; servers: readonly Se
|
|
|
84
86
|
} catch (error) {
|
|
85
87
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
86
88
|
}
|
|
87
|
-
if (
|
|
88
|
-
return { ok: false, error: 'JSON
|
|
89
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
90
|
+
return { ok: false, error: 'JSON 顶层必须是对象:{ "服务器名": { "type": "streamable_http", ... } }' }
|
|
89
91
|
}
|
|
90
|
-
|
|
92
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
93
|
+
if (entries.length === 0) {
|
|
91
94
|
return { ok: false, error: '配置列表不能为空' }
|
|
92
95
|
}
|
|
93
96
|
const servers: ServersJsonEntry[] = []
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
return { ok: false, error:
|
|
97
|
+
for (const [serverName, raw] of entries) {
|
|
98
|
+
if (serverName.trim().length === 0) return { ok: false, error: '服务器名不能为空' }
|
|
99
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
100
|
+
return { ok: false, error: `服务器 "${serverName}" 的配置必须是对象` }
|
|
98
101
|
}
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
const transport = server.transport
|
|
105
|
-
if (transport !== 'stdio' && transport !== 'streamable-http') {
|
|
106
|
-
return { ok: false, error: `服务器 "${serverName}" 的 transport 必须是 stdio 或 streamable-http` }
|
|
102
|
+
const cfg = raw as Record<string, unknown>
|
|
103
|
+
const type = cfg.type
|
|
104
|
+
const transport = type === 'stdio' ? 'stdio' : type === 'streamable_http' ? 'streamable-http' : undefined
|
|
105
|
+
if (transport === undefined) {
|
|
106
|
+
return { ok: false, error: `服务器 "${serverName}" 的 type 必须是 streamable_http 或 stdio` }
|
|
107
107
|
}
|
|
108
|
-
if (transport === 'stdio' && (typeof
|
|
108
|
+
if (transport === 'stdio' && (typeof cfg.command !== 'string' || cfg.command.trim().length === 0)) {
|
|
109
109
|
return { ok: false, error: `服务器 "${serverName}"(stdio)缺少 command` }
|
|
110
110
|
}
|
|
111
|
-
if (transport === 'streamable-http' && typeof
|
|
112
|
-
return { ok: false, error: `服务器 "${serverName}"(
|
|
111
|
+
if (transport === 'streamable-http' && typeof cfg.url !== 'string') {
|
|
112
|
+
return { ok: false, error: `服务器 "${serverName}"(streamable_http)缺少 url` }
|
|
113
113
|
}
|
|
114
|
-
if (
|
|
115
|
-
const headers =
|
|
114
|
+
if (cfg.headers !== undefined) {
|
|
115
|
+
const headers = cfg.headers as unknown
|
|
116
116
|
const okHeaders = (headers !== null && typeof headers === 'object' && !Array.isArray(headers))
|
|
117
117
|
|| (Array.isArray(headers) && headers.every(h => h !== null && typeof h === 'object' && typeof (h as { name?: unknown }).name === 'string'))
|
|
118
118
|
if (!okHeaders) return { ok: false, error: `服务器 "${serverName}" 的 headers 必须是 { "名称": "值" } 对象或 { name, value } 数组` }
|
|
119
119
|
}
|
|
120
|
-
const envValue =
|
|
120
|
+
const envValue = cfg.env
|
|
121
121
|
const env: unknown[] = Array.isArray(envValue) ? envValue : []
|
|
122
122
|
for (const row of env) {
|
|
123
123
|
const r = row as Record<string, unknown> | null
|
|
@@ -131,6 +131,20 @@ export function parseServersJson(text: string): { ok: true; servers: readonly Se
|
|
|
131
131
|
return { ok: false, error: `服务器 "${serverName}" 的 env 条目 "${r.name}" 的 value 必须是字符串` }
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
// Map the key-value document into the wire server shape (host upsertJson
|
|
135
|
+
// consumes serverName/transport/enabled/...).
|
|
136
|
+
const server: Record<string, unknown> = {
|
|
137
|
+
serverName,
|
|
138
|
+
transport,
|
|
139
|
+
enabled: cfg.disabled !== true,
|
|
140
|
+
command: typeof cfg.command === 'string' ? cfg.command : '',
|
|
141
|
+
args: Array.isArray(cfg.args) ? cfg.args : [],
|
|
142
|
+
cwd: typeof cfg.cwd === 'string' ? cfg.cwd : '',
|
|
143
|
+
url: typeof cfg.url === 'string' ? cfg.url : '',
|
|
144
|
+
headers: cfg.headers,
|
|
145
|
+
toolCallTimeoutMs: typeof cfg.toolCallTimeoutMs === 'number' ? cfg.toolCallTimeoutMs : 60000,
|
|
146
|
+
failOnStartupError: cfg.failOnStartupError !== false,
|
|
147
|
+
}
|
|
134
148
|
servers.push({ server, env })
|
|
135
149
|
}
|
|
136
150
|
return { ok: true, servers }
|
package/src/client/locales.ts
CHANGED
|
@@ -75,7 +75,7 @@ export const zh = {
|
|
|
75
75
|
toolsCollapse: '收起工具',
|
|
76
76
|
serversJson: 'JSON 维护配置',
|
|
77
77
|
serversJsonLabel: '服务器配置 JSON',
|
|
78
|
-
serversJsonHint: '整个 MCP
|
|
78
|
+
serversJsonHint: '整个 MCP 服务器配置(key-value 对象,服务器名 → 配置):应用后将按此替换——已列出的服务器创建/更新,未列出的将被删除;type 为 streamable_http 或 stdio,disabled 为 true 表示停用;secret 环境变量值不会导出(configured 标记),留空则保留原值。',
|
|
79
79
|
serversJsonReseed: '重新导出',
|
|
80
80
|
serversJsonApply: '应用',
|
|
81
81
|
serversJsonSaving: '应用中…',
|
|
@@ -178,7 +178,7 @@ export const en = {
|
|
|
178
178
|
toolsCollapse: 'Collapse tools',
|
|
179
179
|
serversJson: 'JSON config editor',
|
|
180
180
|
serversJsonLabel: 'Server-config JSON',
|
|
181
|
-
serversJsonHint: 'The whole MCP server
|
|
181
|
+
serversJsonHint: 'The whole MCP server config as a key-value object (server name → config): applying replaces the list — listed servers are created/updated, servers absent from the document are removed; type is streamable_http or stdio, disabled: true disables the server; secret env values are never exported (a configured flag marks them), a blank value keeps the stored one.',
|
|
182
182
|
serversJsonReseed: 'Re-export',
|
|
183
183
|
serversJsonApply: 'Apply',
|
|
184
184
|
serversJsonSaving: 'Applying…',
|