dsh-mcp 1.5.0 → 1.6.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 CHANGED
@@ -7,6 +7,12 @@ All notable changes to this project are documented in this file.
7
7
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
8
8
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
9
 
10
+ ## [1.6.0] - 2026-08-17
11
+
12
+ ### Added
13
+
14
+ - **MCP tool image results**: image content blocks returned by MCP tools are projected through the attachment service into model image context, with strict type/size/count preflight and degraded text fallbacks; non-image content (audio/resource etc.) gets bounded text fallbacks (PR #4, thanks @coding-chong)
15
+
10
16
  ## [1.5.0] - 2026-08-17
11
17
 
12
18
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@
7
7
  格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
8
8
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
9
9
 
10
+ ## [1.6.0] - 2026-08-17
11
+
12
+ ### 新增
13
+
14
+ - **MCP 工具图片结果**:工具返回的图片内容(image blocks)经附件服务投影为图片引用进入模型上下文,带严格的类型/大小/数量预检与降级文案;非图片内容(audio/resource 等)给出有界文本回退(PR #4,感谢 @coding-chong)
15
+
10
16
  ## [1.5.0] - 2026-08-17
11
17
 
12
18
  ### 新增
package/README.en.md CHANGED
@@ -18,6 +18,7 @@
18
18
  - **Process-level environment variables**: a global key-value list (expanded by default, batch-add supported); header values can reference a variable by bare name or `${NAME}` and are substituted at connect time (e.g. `Authorization: Bearer ${TOKEN}`)
19
19
  - **Whole-list JSON config**: the "JSON config editor" panel views/edits every server as one JSON array; applying saves immediately (create/update/delete)
20
20
  - **Fine-grained tool control**: expand each server to see its tools, all checked by default; uncheck to load only what you need
21
+ - **Image result passthrough**: images returned by MCP tools (screenshots/charts) are projected through the attachment service into model image context, with strict preflight and bounded fallbacks (PR #4)
21
22
  - **Two injection modes**: `search` (on-demand, token-saving) and `full` (inject everything)
22
23
  - **Zero npm dependencies**: plugs into DeepSeek Harness internals, install and go
23
24
  - **OAuth authentication**: for `streamable-http` servers using MCP OAuth (authorization-code + PKCE), the browser opens automatically for authorization on connect; tokens and OAuth client info are persisted and refreshed automatically by the SDK (auto-renewed while active within 24h), with automatic re-authorization after expiry
@@ -152,7 +153,7 @@ Check in order:
152
153
  the `web` profile (`dsh plugin --profile web add dsh-mcp` +
153
154
  `$DSH_HOME/profiles/web/cordis.patch.yml`); other profiles have their own settings pages.
154
155
  5. **Is it the latest version?** npm metadata caching can pin an old version; force the version
155
- with `dsh plugin --profile web add dsh-mcp@latest` (or `@1.4.0`).
156
+ with `dsh plugin --profile web add dsh-mcp@latest` (or `@1.6.0`).
156
157
 
157
158
  **Q2: "MCP" is visible but the server list is empty or errors?**
158
159
 
package/README.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - **进程级环境变量**:全局 KV 配置(默认展开、支持批量添加),服务器请求头 value 写 `变量名` 或 `${变量名}` 即可在连接时自动替换为配置值(如 `Authorization: Bearer ${TOKEN}`)
21
21
  - **JSON 全量配置**:「JSON 维护配置」面板以一段 JSON 数组查看/编辑全部服务器配置,应用即保存(新增/更新/删除)
22
22
  - **工具级精细控制**:每个服务器展开工具列表,默认全选,可取消勾选只加载需要的部分
23
+ - **图片结果透传**:MCP 工具返回的图片(截图/图表等)经附件服务投影为图片引用进入模型上下文,带严格预检与有界降级文案(PR #4)
23
24
  - **双注入模式**:`search`(按需检索,省 token)与 `full`(全量注入)
24
25
  - **零 npm 依赖**:直接对接 DeepSeek Harness 内部能力,安装即用
25
26
  - **OAuth 认证支持**:`streamable-http` 服务器若走 MCP OAuth(授权码 + PKCE),连接时自动打开浏览器授权;token 与 client 信息持久化、由 SDK 自动刷新(24 小时内活跃自动续期),失效后自动重新授权
@@ -135,7 +136,7 @@ dsh plugin --profile web add link:<本仓库绝对路径>
135
136
  (`dsh plugin --profile web add dsh-mcp` + `$DSH_HOME/profiles/web/cordis.patch.yml`);
136
137
  装到其他 profile 则在其他 profile 的设置页查看。
137
138
  5. **是否为最新版本**:npm 元数据缓存可能导致装到旧版,可强制指定版本
138
- `dsh plugin --profile web add dsh-mcp@latest`(或 `@1.4.0`)。
139
+ `dsh plugin --profile web add dsh-mcp@latest`(或 `@1.6.0`)。
139
140
 
140
141
  **Q2:设置页能看到「MCP」,但服务器列表为空/报错?**
141
142
 
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,125 @@ 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":
379
+ case "IMAGE_TOO_LARGE": return "image exceeds the active size limit";
380
+ case "UNSUPPORTED_IMAGE_TYPE":
381
+ case "IMAGE_TYPE_MISMATCH": return "image type is not accepted";
382
+ default: return isImageAdmissionError(error) ? "invalid image data" : "image storage unavailable";
383
+ }
384
+ }
385
+ async function prepareImageProjection(ctx, exec, content, toolName) {
386
+ let attachments;
387
+ try {
388
+ attachments = await resolveImageAdmission(ctx, exec);
389
+ } catch (error) {
390
+ 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";
391
+ return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(reason) }));
392
+ }
393
+ const preflight = preflightImages(content, attachments.imageLimits);
394
+ if (preflight.reason !== void 0) return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(preflight.reason) }));
395
+ let decoded;
396
+ try {
397
+ decoded = preflight.images.map(({ block }) => decodeImage(block));
398
+ } catch {
399
+ return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic("invalid image data") }));
400
+ }
401
+ try {
402
+ const refs = await attachments.saveImages(decoded);
403
+ const byIndex = new Map(preflight.images.map(({ index }, offset) => [index, refs[offset]]));
404
+ return projectContent(content, toolName, (_block, index) => ({ type: "image", attachment: byIndex.get(index) }));
405
+ } catch (error) {
406
+ return projectContent(content, toolName, () => ({ type: "text", text: imageDiagnostic(admissionDiagnostic(error)) }));
407
+ }
408
+ }
282
409
  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]");
410
+ return projectContent(mcpContent, toolName).map((block) => block.text).join("\n");
411
+ }
412
+ function projectContent(mcpContent, toolName, image = () => ({ type: "text", text: imageDiagnostic("this result was not admitted to model image context") })) {
413
+ const projected = [];
414
+ const text = [];
415
+ const flushText = () => {
416
+ if (text.length === 0) return;
417
+ projected.push({ type: "text", text: text.splice(0).join("\n") });
418
+ };
419
+ for (const [index, value] of mcpContent.entries()) {
420
+ if (!isRecord(value)) {
421
+ text.push("[unsupported MCP content block: expected an object]");
287
422
  continue;
288
423
  }
289
424
  const block = value;
290
425
  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":
426
+ case "text": if (block.text !== void 0) text.push(block.text); break;
427
+ case "image": flushText(); projected.push(image(block, index)); break;
301
428
  case "resource_link":
302
- parts.push("[resource: content discarded]");
429
+ if (block.name === void 0 || block.uri === void 0) text.push("[resource link unavailable: the MCP block is missing its name or URI]");
430
+ else text.push(`Resource link: ${block.name} (${block.uri})`);
303
431
  break;
304
- default: parts.push(`[unsupported content type: ${block.type}]`);
432
+ case "audio": text.push("[audio result unsupported; audio bytes were not added to text context]"); break;
433
+ case "resource": text.push("[embedded resource unsupported; resource bytes were not added to text context]"); break;
434
+ default: text.push(`[unsupported MCP content type: ${block.type}]`);
305
435
  }
306
436
  }
307
- return parts.join("\n") || `(${toolName} returned no text content)`;
437
+ flushText();
438
+ return projected.length > 0 ? projected : [{ type: "text", text: `(${toolName} returned no model-visible content)` }];
308
439
  }
309
440
  //#endregion
310
441
  //#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.6.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",