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/CHANGELOG.en.md +25 -0
- package/CHANGELOG.md +25 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/client.js +273 -201
- package/lib/index.js +48 -7
- package/lib/mcp-client.js +53 -5
- package/lib/oauth.js +182 -13
- package/package.json +1 -1
- package/src/client/ServerForm.tsx +9 -1
- package/src/client/locales.ts +8 -2
- package/src/client/mcp-store.ts +62 -1
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
|
-
|
|
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
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
113
|
+
let authQueue = Promise.resolve();
|
|
109
114
|
|
|
110
|
-
function withAuthLock(
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
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.
|
|
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={
|
|
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
|
) : (
|
package/src/client/locales.ts
CHANGED
|
@@ -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: '
|
|
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',
|
package/src/client/mcp-store.ts
CHANGED
|
@@ -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:
|
|
203
|
+
args: parseArgs(draft.argsText),
|
|
143
204
|
cwd: draft.cwd.trim(),
|
|
144
205
|
url: draft.url.trim(),
|
|
145
206
|
headers: parseHeaders(draft.headersText),
|