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/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
- return {
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
- const parts = [];
284
- for (const value of mcpContent) {
285
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
286
- parts.push("[unsupported content type: unknown]");
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
- if (block.text !== void 0) parts.push(block.text);
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
- parts.push("[resource: content discarded]");
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
- default: parts.push(`[unsupported content type: ${block.type}]`);
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
- return parts.join("\n") || `(${toolName} returned no text content)`;
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.5.0",
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. The document is an array of server definitions (the same
4
- * shape the add/edit form submits); applying it replaces the whole list —
5
- * listed servers are created or updated, existing servers absent from the
6
- * document are removed. Secret env values are never exported (a `configured`
7
- * flag marks them) and a blank secret value keeps the stored one.
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
- * "serverName": "feishu-mcp",
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
- * "toolCallTimeoutMs": 60000,
17
- * "failOnStartupError": true,
18
- * "env": []
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 = servers.map((server) => {
55
+ const document: Record<string, Record<string, unknown>> = {}
56
+ for (const server of servers) {
54
57
  const entry: Record<string, unknown> = {
55
- serverName: server.serverName,
56
- transport: server.transport,
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
- return entry
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 (!Array.isArray(value)) {
88
- return { ok: false, error: 'JSON 顶层必须是服务器配置数组:[ { serverName, transport, ... }, ... ]' }
89
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
90
+ return { ok: false, error: 'JSON 顶层必须是对象:{ "服务器名": { "type": "streamable_http", ... } }' }
89
91
  }
90
- if (value.length === 0) {
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 seen = new Set<string>()
95
- for (const item of value) {
96
- if (item === null || typeof item !== 'object' || Array.isArray(item)) {
97
- return { ok: false, error: '每个条目必须是对象:{ serverName, transport, ... }' }
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 server = item as Record<string, unknown>
100
- const serverName = typeof server.serverName === 'string' ? server.serverName.trim() : ''
101
- if (serverName.length === 0) return { ok: false, error: 'serverName 不能为空' }
102
- if (seen.has(serverName)) return { ok: false, error: `serverName "${serverName}" 重复` }
103
- seen.add(serverName)
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 server.command !== 'string' || server.command.trim().length === 0)) {
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 server.url !== 'string') {
112
- return { ok: false, error: `服务器 "${serverName}"(streamable-http)缺少 url` }
111
+ if (transport === 'streamable-http' && typeof cfg.url !== 'string') {
112
+ return { ok: false, error: `服务器 "${serverName}"(streamable_http)缺少 url` }
113
113
  }
114
- if (server.headers !== undefined) {
115
- const headers = server.headers as unknown
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 = server.env
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 }
@@ -75,7 +75,7 @@ export const zh = {
75
75
  toolsCollapse: '收起工具',
76
76
  serversJson: 'JSON 维护配置',
77
77
  serversJsonLabel: '服务器配置 JSON',
78
- serversJsonHint: '整个 MCP 服务器配置列表(数组):应用后将按此列表替换——已列出的服务器创建/更新,未列出的将被删除;secret 环境变量值不会导出(configured 标记),留空则保留原值。',
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 list (array): applying replaces the list — listed servers are created/updated, existing servers absent from the document are removed; secret env values are never exported (a configured flag marks them), a blank value keeps the stored one.',
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…',