dsh-mcp 1.7.0 → 1.9.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/index.js CHANGED
@@ -266,7 +266,13 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
266
266
  const SECRET_REF_PREFIX = "DSH_MCP_";
267
267
  /** Credential reference namespace prefix for process-level secret env values. */
268
268
  const GLOBAL_ENV_REF_PREFIX = "DSH_MCP_ENV_";
269
- const Config = z.object({ probeTimeoutMs: z.number().min(1).default(15e3) });
269
+ /** Credential reference namespace prefix for per-server OAuth tokens/clients (see lib/oauth.js). */
270
+ const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
271
+ const Config = z.object({
272
+ probeTimeoutMs: z.number().min(1).default(15e3),
273
+ /** When true, mounts open the browser for OAuth authorization on their own (legacy behavior); default false keeps mounts browser-free. */
274
+ allowBrowserOnMount: z.boolean().default(false)
275
+ });
270
276
  /** The mcp-client plugin as a Cordis object plugin, mounted per server. */
271
277
  const MCP_CLIENT_PLUGIN = {
272
278
  name: mcpClient.name,
@@ -468,6 +474,8 @@ let McpManagerService = (() => {
468
474
  /** Server ids whose secret writes are this manager's own; skip restart echoes. */
469
475
  suppressRestart = /* @__PURE__ */ new Set();
470
476
  probeTimeoutMs;
477
+ /** Whether mounts may open the browser for OAuth authorization (see {@link Config.allowBrowserOnMount}). */
478
+ allowBrowserOnMount;
471
479
  /** Per-tool enable switches: a stored `false` disables that tool; absent means enabled. */
472
480
  toolSwitches = /* @__PURE__ */ new Map();
473
481
  /** Injection mode: `search` (default) injects only resident + hot tools; `full` injects every enabled tool. */
@@ -483,6 +491,7 @@ let McpManagerService = (() => {
483
491
  constructor(ctx, config) {
484
492
  super(ctx, "mcpManager");
485
493
  this.probeTimeoutMs = config.probeTimeoutMs;
494
+ this.allowBrowserOnMount = config.allowBrowserOnMount;
486
495
  }
487
496
  /** Open the storage domain, mount stored servers, and subscribe to secret changes. */
488
497
  async [Service.init]() {
@@ -495,7 +504,28 @@ let McpManagerService = (() => {
495
504
  this.envTable = domain.table("global_env");
496
505
  for (const [id, row] of this.table.entries()) if (row.enabled) this.mount(id, row);
497
506
  this.installToolControl();
498
- this.ctx.on("credentials/updated", (ref) => {
507
+ this.ctx.on("credentials/reference-updated", (ref) => {
508
+ // OAuth token/client refs (`DSH_MCP_OAUTH_<serverName>_<hash>` /
509
+ // `DSH_MCP_OAUTH_CLIENT_...`) do not carry the managed server id,
510
+ // so managedServerId() cannot resolve them. Resolve by the
511
+ // sanitized serverName instead: after an authorization flow
512
+ // stores tokens, remount every enabled server of that name so
513
+ // its tools register without a manual refresh.
514
+ if (ref.startsWith(OAUTH_REF_PREFIX)) {
515
+ const rest = ref.slice(OAUTH_REF_PREFIX.length);
516
+ const separator = rest.lastIndexOf("_");
517
+ if (separator > 0) {
518
+ const safeName = rest.slice(0, separator);
519
+ for (const [id, row] of this.requireTable().entries()) {
520
+ if (row.enabled && row.serverName.replace(/[^A-Za-z0-9_]/g, "_") === safeName && !this.suppressRestart.has(id)) {
521
+ this.serialize(id, async () => {
522
+ if (this.requireTable().get(id) !== void 0) await this.reconcileMount(id);
523
+ });
524
+ }
525
+ }
526
+ }
527
+ return;
528
+ }
499
529
  const id = managedServerId(ref);
500
530
  if (id === void 0 || this.suppressRestart.has(id)) return;
501
531
  this.serialize(id, async () => {
@@ -595,10 +625,11 @@ let McpManagerService = (() => {
595
625
  const globalEnv = await this.resolveGlobalEnv();
596
626
  // Every server gets an OAuth provider: servers that require
597
627
  // OAuth (authorization-code flow) trigger browser auth on
598
- // connect; servers that do not are unaffected. Tokens are
599
- // keyed by serverName so a test-connection authorization is
600
- // reused by the mount.
601
- const authProvider = createOAuthProvider(row.serverName, this.ctx);
628
+ // connect (unless allowBrowserOnMount is off, the default);
629
+ // servers that do not are unaffected. Tokens are keyed by
630
+ // serverName so a test-connection authorization is reused
631
+ // by the mount.
632
+ const authProvider = createOAuthProvider(row.serverName, this.ctx, { allowBrowser: this.allowBrowserOnMount, serverUrl: row.url });
602
633
  handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, { ...this.toClientConfig(row, env, globalEnv), authProvider });
603
634
  } catch (error) {
604
635
  entry.phase = "failed";
@@ -1018,7 +1049,7 @@ let McpManagerService = (() => {
1018
1049
  const globalEnv = await this.resolveGlobalEnv();
1019
1050
  const probe = await probeConnection(this.toClientConfig(request.server, env, globalEnv), {
1020
1051
  timeoutMs: this.probeTimeoutMs,
1021
- authProvider: createOAuthProvider(request.server.serverName, this.ctx)
1052
+ authProvider: createOAuthProvider(request.server.serverName, this.ctx, { serverUrl: request.server.url })
1022
1053
  });
1023
1054
  const elapsedMs = Date.now() - startedAt;
1024
1055
  return {
@@ -1151,6 +1182,16 @@ let McpManagerService = (() => {
1151
1182
  });
1152
1183
  }
1153
1184
  assembly.tools = kept;
1185
+ // Canonical tool order: render MCP tools sorted by name so the
1186
+ // system prompt stays byte-stable for the same tool set no
1187
+ // matter the registration or hot-set order (hot tools move on
1188
+ // every call, and servers may reorder tools/list) — stable
1189
+ // output is what preserves prompt-cache hits.
1190
+ const mcpKept = [];
1191
+ const otherKept = [];
1192
+ for (const tool of assembly.tools) (tool.name.startsWith("mcp__") ? mcpKept : otherKept).push(tool);
1193
+ mcpKept.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1194
+ assembly.tools = [...otherKept, ...mcpKept];
1154
1195
  return next();
1155
1196
  });
1156
1197
  this.ctx.on("tools/result", (exec) => {
package/lib/mcp-client.js CHANGED
@@ -10,6 +10,7 @@ import { isDeepStrictEqual } from "node:util";
10
10
  import { z as z$1 } from "zod";
11
11
  import { isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
12
12
  import { assertSupportedJsonSchema } from "@deepseek-ai/dsh-tools";
13
+ import { createToolAuthorizationUrl } from "./oauth.js";
13
14
  //#region lib/types/transport.js
14
15
  /**
15
16
  * Transport factory: creates the appropriate MCP transport based on the
@@ -204,9 +205,22 @@ function toolPublicEqual(a, b) {
204
205
  if (!jsonEqual(a.parameters, b.parameters)) return false;
205
206
  return jsonEqual(a.output && a.output.schema, b.output && b.output.schema);
206
207
  }
207
- /** Deep equality of JSON-serializable values (schema objects). */
208
+ /**
209
+ * Recursively sort object keys so semantically identical JSON compares equal
210
+ * regardless of key order (servers often reorder schema keys between lists).
211
+ */
212
+ function canonicalize(value) {
213
+ if (Array.isArray(value)) return value.map(canonicalize);
214
+ if (value !== null && typeof value === "object") {
215
+ const out = {};
216
+ for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
217
+ return out;
218
+ }
219
+ return value;
220
+ }
221
+ /** Deep equality of JSON-serializable values, ignoring object key order. */
208
222
  function jsonEqual(a, b) {
209
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
223
+ return JSON.stringify(canonicalize(a ?? null)) === JSON.stringify(canonicalize(b ?? null));
210
224
  }
211
225
  /** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
212
226
  function supportedOutputSchema(candidate) {
@@ -224,7 +238,11 @@ function createDefinition(client, ctx, publicName, rawName, description, paramet
224
238
  return {
225
239
  name: publicName,
226
240
  description,
227
- parameters,
241
+ // Canonicalize before registration: servers often return JSON Schema
242
+ // with unstable key ordering; sorting keys keeps the model-visible tool
243
+ // list byte-stable across re-syncs and reconnects, which is what
244
+ // preserves prompt-cache hits.
245
+ parameters: canonicalize(parameters),
228
246
  output: createOutput(rawName, structuredSchema),
229
247
  execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
230
248
  finalizeContent(exec, result) {
@@ -248,7 +266,7 @@ function createOutput(rawName, structuredSchema) {
248
266
  type: "array",
249
267
  items: {}
250
268
  },
251
- structuredContent: structuredSchema ?? {}
269
+ structuredContent: canonicalize(structuredSchema ?? {})
252
270
  },
253
271
  required: structuredSchema === void 0 ? ["content"] : ["content", "structuredContent"],
254
272
  additionalProperties: false
@@ -274,6 +292,30 @@ function createOutput(rawName, structuredSchema) {
274
292
  function createExecutor(client, ctx, rawName, taskRequired, opts, projections) {
275
293
  return async (args, exec) => {
276
294
  if (taskRequired) throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
295
+ // OAuth servers: if the stored token is missing or expired, do NOT let
296
+ // the SDK open a browser from inside a tool call (a session could open
297
+ // several tabs at once). Instead, build an authorization URL and return
298
+ // it for the user to click; a background listener completes the flow.
299
+ if (opts.authProvider !== void 0 && opts.authProvider.tokens !== void 0) {
300
+ const tokens = await opts.authProvider.tokens();
301
+ const expired = tokens === void 0 || tokens.access_token === void 0 || (() => {
302
+ try {
303
+ const payload = JSON.parse(Buffer.from(String(tokens.access_token).split(".")[1] ?? "", "base64url").toString("utf8"));
304
+ return typeof payload.exp === "number" && Date.now() / 1000 >= payload.exp;
305
+ } catch {
306
+ return false;
307
+ }
308
+ })();
309
+ if (expired) {
310
+ try {
311
+ const authUrl = await createToolAuthorizationUrl(opts.serverName, ctx, opts.url);
312
+ throw new Error(`服务器 "${opts.serverName}" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试:\n${authUrl}`);
313
+ } catch (error) {
314
+ if (error instanceof Error && error.message.includes("授权")) throw error;
315
+ throw new Error(`服务器 "${opts.serverName}" 需要 OAuth 授权:请在 Settings → MCP 编辑该服务器并点击「测试连接」完成授权后重试`);
316
+ }
317
+ }
318
+ }
277
319
  const result = await callToolUncached(client, rawName, typeof args === "object" && args !== null ? args : {}, exec, opts);
278
320
  if (!Array.isArray(result.content)) {
279
321
  const rendered = "toolResult" in result ? JSON.stringify(result.toolResult) : "(no output)";
@@ -862,7 +904,13 @@ async function apply(ctx, config) {
862
904
  return () => connection.dispose();
863
905
  }, "mcp-client.connection");
864
906
  const outcome = await connection.ready;
865
- if (outcome.error !== void 0 && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
907
+ // Keep the cause readable: the mount failure view only surfaces the
908
+ // message, so fold the underlying detail (e.g. an OAuth authorization
909
+ // link) into it instead of burying it in `cause`.
910
+ if (outcome.error !== void 0 && config.failOnStartupError) {
911
+ const detail = outcome.error instanceof Error && outcome.error.message ? outcome.error.message : String(outcome.error);
912
+ throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed: ${detail}`, { cause: outcome.error });
913
+ }
866
914
  }
867
915
  //#endregion
868
916
  export { Config, apply, inject, name, probeConnection };
package/lib/oauth.js CHANGED
@@ -15,7 +15,10 @@ import { createServer } from "node:http";
15
15
  import { spawn } from "node:child_process";
16
16
  import {
17
17
  discoverAuthorizationServerMetadata,
18
+ discoverOAuthServerInfo,
18
19
  exchangeAuthorization,
20
+ registerClient,
21
+ startAuthorization,
19
22
  } from "@modelcontextprotocol/sdk/client/auth.js";
20
23
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
21
24
 
@@ -100,17 +103,18 @@ function stablePort(serverId) {
100
103
  }
101
104
 
102
105
  /**
103
- * Serialize authorization flows per server: the loopback port is stable per
104
- * server, so two concurrent flows (e.g. mount and a test connection) would
105
- * both try to listen on the same port and one fails with EADDRINUSE. Queue
106
- * them so only one browser authorization runs at a time per server.
106
+ * Serialize ALL authorization flows globally: each server's loopback port is
107
+ * stable, so concurrent flows (multiple mounts, mount + test, several OAuth
108
+ * servers at startup) would otherwise each open a browser tab at once. A
109
+ * single global queue means at most one browser authorization is running at
110
+ * any time; a flow that gets the lock after another one stored fresh tokens
111
+ * skips the browser entirely (see redirectToAuthorization).
107
112
  */
108
- const authLocks = /* @__PURE__ */ new Map();
113
+ let authQueue = Promise.resolve();
109
114
 
110
- function withAuthLock(serverId, fn) {
111
- const previous = authLocks.get(serverId) ?? Promise.resolve();
112
- const run = previous.then(fn, fn);
113
- authLocks.set(serverId, run.then(() => {}, () => {}));
115
+ function withAuthLock(fn) {
116
+ const run = authQueue.then(fn, fn);
117
+ authQueue = run.then(() => {}, () => {});
114
118
  return run;
115
119
  }
116
120
 
@@ -120,9 +124,11 @@ function withAuthLock(serverId, fn) {
120
124
  * @param ctx - Host context carrying the credentials service.
121
125
  * @returns an MCP SDK `OAuthClientProvider` implementation.
122
126
  */
123
- export function createOAuthProvider(serverId, ctx) {
127
+ export function createOAuthProvider(serverId, ctx, options = {}) {
124
128
  let codeVerifierValue = null;
125
129
  let savedClientInfo = null;
130
+ const allowBrowser = options.allowBrowser !== false;
131
+ const serverUrl = options.serverUrl;
126
132
  const port = stablePort(serverId);
127
133
  const redirectUrl = `http://127.0.0.1:${port}/callback`;
128
134
  const log = ctx?.logger ?? console;
@@ -215,9 +221,48 @@ export function createOAuthProvider(serverId, ctx) {
215
221
  return codeVerifierValue;
216
222
  },
217
223
  async redirectToAuthorization(authorizationUrl) {
218
- // Serialize per server: the loopback port is stable, so concurrent
219
- // flows (mount + test) would collide on the same port. Queue them.
220
- return withAuthLock(serverId, async () => {
224
+ // Global serialization: at most one browser authorization runs at a
225
+ // time (stable per-server ports would otherwise collide, and several
226
+ // OAuth servers at startup would open many tabs at once).
227
+ return withAuthLock(async () => {
228
+ // Another queued flow may have just completed authorization and
229
+ // stored fresh tokens while we waited. If so, skip the browser — the
230
+ // SDK will throw on this attempt and the next connect uses the token.
231
+ const existing = await ctx.credentials.resolve(oauthTokenRef(serverId));
232
+ if (existing !== void 0) {
233
+ try {
234
+ const stored = JSON.parse(existing.value);
235
+ const exp = jwtExp(stored.access_token);
236
+ if (exp === void 0 || Date.now() / 1000 < exp) {
237
+ log.info(`${tag}: tokens already refreshed by another flow, skipping browser`);
238
+ return;
239
+ }
240
+ } catch {
241
+ // malformed stored token — fall through and authorize
242
+ }
243
+ }
244
+ if (serverUrl !== void 0) {
245
+ // One shared pending-callback listener per server:
246
+ // createToolAuthorizationUrl deduplicates by serverId, so a mount
247
+ // failure and a test connection reuse the same loopback listener
248
+ // instead of both binding the stable port (an unhandled EADDRINUSE
249
+ // on listen would crash the Host).
250
+ const url = await createToolAuthorizationUrl(serverId, ctx, serverUrl);
251
+ if (!allowBrowser) {
252
+ // Mount-time connections must not pop the browser on their own;
253
+ // fail with the clickable authorization link. The shared
254
+ // listener keeps running so opening the URL completes the flow.
255
+ log.info(`${tag}: mount requires authorization, not opening browser`);
256
+ throw new Error(`服务器 "${serverId}" 需要 OAuth 授权,请在浏览器打开以下链接完成授权后重试(也可在 Settings → MCP 点击「测试连接」):\n${url}`);
257
+ }
258
+ log.info(`${tag}: opening browser for authorization: ${url}`);
259
+ openBrowser(url);
260
+ const pending = pendingCallbacks.get(serverId);
261
+ if (pending !== void 0) await pending.done;
262
+ return;
263
+ }
264
+ // Fallback (no server URL known): legacy loopback listener with an
265
+ // error handler so a bind failure can never crash the Host.
221
266
  let resolveResult;
222
267
  const resultPromise = new Promise((resolve) => {
223
268
  resolveResult = resolve;
@@ -237,6 +282,10 @@ export function createOAuthProvider(serverId, ctx) {
237
282
  log.info(`${tag}: authorization callback received (code)`);
238
283
  resolveResult({ code });
239
284
  });
285
+ server.on("error", (error) => {
286
+ log.error(`${tag}: callback server error: ${error instanceof Error ? error.message : String(error)}`);
287
+ resolveResult({ error: error instanceof Error ? error.message : String(error) });
288
+ });
240
289
  await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
241
290
  log.info(`${tag}: opening browser for authorization: ${authorizationUrl}`);
242
291
  openBrowser(authorizationUrl.toString());
@@ -273,3 +322,123 @@ export function createOAuthProvider(serverId, ctx) {
273
322
  },
274
323
  };
275
324
  }
325
+
326
+ /** Pending background authorization callbacks per server (one at a time). */
327
+ const pendingCallbacks = /* @__PURE__ */ new Map();
328
+
329
+ /**
330
+ * Build an authorization URL for a tool call that needs authorization and
331
+ * start a background loopback listener that completes the flow when the user
332
+ * opens the URL. The browser is NOT opened automatically — the caller returns
333
+ * the URL to the model/user to click. Concurrent requests for the same server
334
+ * reuse the pending flow.
335
+ * @param serverId - Managed server id (stable loopback port + token key).
336
+ * @param ctx - Host context carrying credentials.
337
+ * @param serverUrl - The MCP server URL (used for OAuth discovery).
338
+ * @returns the authorization URL to present to the user.
339
+ */
340
+ export async function createToolAuthorizationUrl(serverId, ctx, serverUrl) {
341
+ const port = stablePort(serverId);
342
+ const redirectUrl = `http://127.0.0.1:${port}/callback`;
343
+ const log = ctx?.logger ?? console;
344
+ const tag = `mcp-oauth(${serverId})`;
345
+ const existing = pendingCallbacks.get(serverId);
346
+ if (existing !== void 0) return existing.url;
347
+
348
+ const info = await discoverOAuthServerInfo(serverUrl, { fetchFn: fetch });
349
+ let clientInfo = await ctx.credentials.resolve(oauthClientRef(serverId));
350
+ if (clientInfo !== void 0) {
351
+ try {
352
+ clientInfo = JSON.parse(clientInfo.value);
353
+ } catch {
354
+ clientInfo = void 0;
355
+ }
356
+ if (clientInfo !== void 0 && (!Array.isArray(clientInfo.redirect_uris) || !clientInfo.redirect_uris.includes(redirectUrl))) {
357
+ clientInfo = void 0; // stale redirect_uris → re-register
358
+ }
359
+ }
360
+ if (clientInfo === void 0) {
361
+ clientInfo = await registerClient(info.authorizationServerUrl, {
362
+ metadata: info.authorizationServerMetadata,
363
+ clientMetadata: {
364
+ client_name: "dsh-mcp",
365
+ redirect_uris: [redirectUrl],
366
+ },
367
+ fetchFn: fetch,
368
+ });
369
+ await ctx.credentials.set(oauthClientRef(serverId), JSON.stringify(clientInfo));
370
+ }
371
+ // The SDK expects a URL object (it reads `resource.href`); passing a bare
372
+ // string would serialize the resource parameter as "undefined". Prefer the
373
+ // protected-resource metadata's resource when available.
374
+ const resourceUrl = info.resourceMetadata?.resource !== void 0
375
+ ? new URL(info.resourceMetadata.resource)
376
+ : new URL(serverUrl);
377
+ const { authorizationUrl, codeVerifier } = await startAuthorization(info.authorizationServerUrl, {
378
+ metadata: info.authorizationServerMetadata,
379
+ clientInformation: clientInfo,
380
+ redirectUrl,
381
+ resource: resourceUrl,
382
+ });
383
+ const url = String(authorizationUrl);
384
+
385
+ // Background listener: wait for the user to open the URL and authorize.
386
+ const pending = {
387
+ url,
388
+ done: new Promise((resolve) => {
389
+ const server = createServer(async (req, res) => {
390
+ const callbackUrl = new URL(req.url, redirectUrl);
391
+ const code = callbackUrl.searchParams.get("code");
392
+ const error = callbackUrl.searchParams.get("error");
393
+ res.setHeader("content-type", "text/html; charset=utf-8");
394
+ if (error) {
395
+ res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
396
+ log.error(`${tag}: callback rejected: ${error}`);
397
+ resolve();
398
+ server.close();
399
+ return;
400
+ }
401
+ res.end("授权成功,可关闭此页面并返回 DSH。");
402
+ log.info(`${tag}: tool-auth callback received (code)`);
403
+ try {
404
+ const authServerUrl = new URL(authorizationUrl.origin);
405
+ const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
406
+ const tokens = await exchangeAuthorization(authServerUrl, {
407
+ metadata,
408
+ clientInformation: clientInfo,
409
+ authorizationCode: code,
410
+ codeVerifier,
411
+ redirectUri: redirectUrl,
412
+ });
413
+ await ctx.credentials.set(oauthTokenRef(serverId), JSON.stringify(tokens));
414
+ log.info(`${tag}: tool-auth tokens saved`);
415
+ } catch (exchangeError) {
416
+ log.error(`${tag}: tool-auth exchange failed: ${exchangeError instanceof Error ? exchangeError.message : String(exchangeError)}`);
417
+ }
418
+ resolve();
419
+ server.close();
420
+ });
421
+ // Never let a bind failure crash the Host: resolve the pending flow so
422
+ // callers surface a readable error instead of an unhandled event.
423
+ server.on("error", (listenError) => {
424
+ log.error(`${tag}: tool-auth listener error: ${listenError instanceof Error ? listenError.message : String(listenError)}`);
425
+ resolve();
426
+ });
427
+ server.listen(port, "127.0.0.1", () => {
428
+ log.info(`${tag}: tool-auth listener ready on :${port}`);
429
+ });
430
+ // Give up after 5 minutes so the listener never leaks.
431
+ const timer = setTimeout(() => {
432
+ log.info(`${tag}: tool-auth listener expired`);
433
+ server.close();
434
+ resolve();
435
+ }, 5 * 60e3);
436
+ timer.unref?.();
437
+ }),
438
+ };
439
+ pendingCallbacks.set(serverId, pending);
440
+ void pending.done.finally(() => {
441
+ if (pendingCallbacks.get(serverId) === pending) pendingCallbacks.delete(serverId);
442
+ });
443
+ return url;
444
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",
@@ -3,6 +3,7 @@ import type { McpManagerFailure, McpServerId, McpServerView } from './types.ts'
3
3
  import type {
4
4
  McpDraft, McpTestOutcome,
5
5
  } from './mcp-store.ts'
6
+ import { parseArgs } from './mcp-store.ts'
6
7
  import type { McpSettingsLocaleKey } from './locales.ts'
7
8
  import css from './ServerForm.module.css'
8
9
 
@@ -168,11 +169,17 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
168
169
  <label className={css.field}>
169
170
  <span>{t('args')}</span>
170
171
  <textarea
171
- rows={2}
172
+ rows={3}
172
173
  value={draft.argsText}
173
174
  placeholder={t('argsPlaceholder')}
174
175
  onChange={(event) => update({ argsText: event.currentTarget.value })}
175
176
  />
177
+ {(() => {
178
+ const parsed = parseArgs(draft.argsText)
179
+ if (parsed.length === 0) return <span className={css.muted}>{t('argsPreviewEmpty')}</span>
180
+ const shown = parsed.map(arg => arg.length > 48 ? `${arg.slice(0, 45)}…` : arg).join(' | ')
181
+ return <span className={css.muted}>{t('argsPreview')}: {parsed.length} · {shown}</span>
182
+ })()}
176
183
  </label>
177
184
  <label className={css.field}>
178
185
  <span>{t('cwd')}</span>
@@ -182,6 +189,7 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
182
189
  placeholder={t('cwdPlaceholder')}
183
190
  onChange={(event) => update({ cwd: event.currentTarget.value })}
184
191
  />
192
+ <span className={css.muted}>{t('cwdHint')}</span>
185
193
  </label>
186
194
  </>
187
195
  ) : (
@@ -18,9 +18,12 @@ export const zh = {
18
18
  command: '命令',
19
19
  commandPlaceholder: '例如 npx',
20
20
  args: '参数',
21
- argsPlaceholder: '每行一个参数',
21
+ argsPlaceholder: '空格/换行分隔,支持引号,如:-y @modelcontextprotocol/server-filesystem /path/to/dir',
22
+ argsPreview: '参数预览',
23
+ argsPreviewEmpty: '(无参数)',
22
24
  cwd: '工作目录',
23
25
  cwdPlaceholder: '留空继承 Host 工作目录',
26
+ cwdHint: '留空会继承 DSH 的工作目录。若该目录是 pnpm workspace,npx 等命令可能解析到错误的本地包;建议填一个普通目录的绝对路径。',
24
27
  url: '服务器 URL',
25
28
  urlPlaceholder: '例如 http://localhost:3000/mcp',
26
29
  headers: '请求头',
@@ -121,9 +124,12 @@ export const en = {
121
124
  command: 'Command',
122
125
  commandPlaceholder: 'e.g. npx',
123
126
  args: 'Arguments',
124
- argsPlaceholder: 'One argument per line',
127
+ argsPlaceholder: 'Space/newline separated, quotes supported, e.g. -y @modelcontextprotocol/server-filesystem /path/to/dir',
128
+ argsPreview: 'Argument preview',
129
+ argsPreviewEmpty: '(no arguments)',
125
130
  cwd: 'Working directory',
126
131
  cwdPlaceholder: 'Leave empty to inherit the Host working directory',
132
+ cwdHint: 'An empty value inherits the DSH working directory. If that directory is a pnpm workspace, npx and similar commands may resolve to a wrong local package; prefer an absolute path to a plain directory.',
127
133
  url: 'Server URL',
128
134
  urlPlaceholder: 'e.g. http://localhost:3000/mcp',
129
135
  headers: 'Headers',
@@ -103,6 +103,67 @@ function parseLines(text: string): string[] {
103
103
  return text.split('\n').map(line => line.trim()).filter(line => line.length > 0)
104
104
  }
105
105
 
106
+ /**
107
+ * Shell-style argument parsing: split on whitespace (spaces, tabs, newlines)
108
+ * while honoring single quotes, double quotes (with `\"` and `\\` escapes),
109
+ * and backslash escapes outside quotes. Unclosed quotes are tolerated: the
110
+ * remaining input becomes one argument. An explicitly empty quoted argument
111
+ * (`""` or `''`) is preserved.
112
+ */
113
+ export function parseArgs(text: string): string[] {
114
+ const args: string[] = []
115
+ let current = ''
116
+ let quote: '"' | "'" | null = null
117
+ let hasToken = false
118
+ let i = 0
119
+ while (i < text.length) {
120
+ const ch = text[i]
121
+ if (quote !== null) {
122
+ if (ch === quote) {
123
+ quote = null
124
+ i += 1
125
+ continue
126
+ }
127
+ if (ch === '\\' && quote === '"' && i + 1 < text.length && (text[i + 1] === '"' || text[i + 1] === '\\')) {
128
+ current += text[i + 1]
129
+ i += 2
130
+ continue
131
+ }
132
+ current += ch
133
+ i += 1
134
+ continue
135
+ }
136
+ if (ch === '"' || ch === "'") {
137
+ quote = ch
138
+ hasToken = true
139
+ i += 1
140
+ continue
141
+ }
142
+ if (ch === '\\' && i + 1 < text.length) {
143
+ current += text[i + 1]
144
+ hasToken = true
145
+ i += 2
146
+ continue
147
+ }
148
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
149
+ if (hasToken || current.length > 0) {
150
+ args.push(current)
151
+ current = ''
152
+ hasToken = false
153
+ }
154
+ i += 1
155
+ continue
156
+ }
157
+ current += ch
158
+ hasToken = true
159
+ i += 1
160
+ }
161
+ if (quote !== null || hasToken || current.length > 0) {
162
+ args.push(current)
163
+ }
164
+ return args
165
+ }
166
+
106
167
  /** Parse "Name: value" header lines; malformed lines are dropped. */
107
168
  function parseHeaders(text: string): Array<{ name: string; value: string }> {
108
169
  const headers: Array<{ name: string; value: string }> = []
@@ -139,7 +200,7 @@ export function draftToSubmission(draft: McpDraft): McpSubmission {
139
200
  transport: draft.transport,
140
201
  enabled: draft.enabled,
141
202
  command: draft.command.trim(),
142
- args: parseLines(draft.argsText),
203
+ args: parseArgs(draft.argsText),
143
204
  cwd: draft.cwd.trim(),
144
205
  url: draft.url.trim(),
145
206
  headers: parseHeaders(draft.headersText),