@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.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.
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Canonical MCP server model plus the dialect machinery every adapter shares.
3
+ *
4
+ * The fifteen-ish clients in the wild disagree on almost everything: the
5
+ * container key, whether `type` exists, five spellings for streamable HTTP,
6
+ * `env` vs `environment`, `command` as string vs array, three timeout units.
7
+ * None of that leaks upward: adapters decode into this model and encode back,
8
+ * preserving fields they do not understand so a rewrite never loses a user's
9
+ * client-specific option.
10
+ *
11
+ * Canonical transport is three values. Clients that key off the URL rather than
12
+ * a type tag still round-trip correctly because the decoder reads the shape.
13
+ */
14
+
15
+ export const TRANSPORTS = Object.freeze(['stdio', 'http-stream', 'http-sse']);
16
+
17
+ /** Fields the canonical model owns; everything else rides in `extra`. */
18
+ const OWNED = new Set([
19
+ 'type', 'command', 'args', 'env', 'environment', 'cwd',
20
+ 'url', 'serverUrl', 'httpUrl', 'headers', 'disabled', 'enabled',
21
+ 'timeout', 'timeoutMs', 'toolCallTimeoutMs',
22
+ 'autoApprove', 'alwaysAllow',
23
+ ]);
24
+
25
+ /* ------------------------------------------------------------- secrets ---- */
26
+
27
+ const SECRET_KEY = /(token|key|secret|password|passwd|credential|auth|cookie|session|private)/i;
28
+
29
+ /** True when an env/header key name looks like it carries a credential. */
30
+ export function isSecretKey(key) {
31
+ return typeof key === 'string' && SECRET_KEY.test(key);
32
+ }
33
+
34
+ /**
35
+ * True when a value is an indirection rather than the credential itself —
36
+ * `${VAR}`, `{{x}}`, `{env:X}`, `%VAR%`, `<placeholder>`. These are safe to show
37
+ * because they name a secret instead of carrying one.
38
+ */
39
+ export function isReferenceValue(value) {
40
+ return typeof value === 'string' && /^\$\{|\{\{|\$env:|^\{env:|^%[A-Za-z_]+%$|^<.*>$/.test(value);
41
+ }
42
+
43
+ /**
44
+ * A literal value shaped like a real credential (long, opaque).
45
+ *
46
+ * Only used to *rank* findings — never to decide whether to mask. Masking is
47
+ * key-driven, because a short secret (`ZZ_TOKEN: abc`) is still a secret and a
48
+ * heuristic that inspects values would happily print it.
49
+ */
50
+ export function isSecretValue(value) {
51
+ if (typeof value !== 'string') return false;
52
+ if (value.length < 12) return false;
53
+ if (isReferenceValue(value)) return false;
54
+ return /[A-Za-z0-9_\-]{16,}/.test(value) || value.length >= 24;
55
+ }
56
+
57
+ export function maskValue(value) {
58
+ const text = String(value ?? '');
59
+ if (text.length <= 8) return '••••';
60
+ return `${text.slice(0, 4)}••••${text.slice(-4)}`;
61
+ }
62
+
63
+ /**
64
+ * Is this string a placeholder produced by `maskValue`?
65
+ *
66
+ * A masked copy must never be written back to a config file: it would replace a
67
+ * real credential with bullets and silently break the server. Write paths use
68
+ * this to refuse, and to explain why.
69
+ */
70
+ export function isMaskedValue(value) {
71
+ return typeof value === 'string' && value.includes('••••');
72
+ }
73
+
74
+ /* ---------------------------------------------------------- normalization -- */
75
+
76
+ function asStringArray(value) {
77
+ if (Array.isArray(value)) return value.map((item) => String(item));
78
+ if (value === undefined || value === null || value === '') return [];
79
+ return [String(value)];
80
+ }
81
+
82
+ function asObject(value) {
83
+ return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
84
+ }
85
+
86
+ function pickTimeout(entry) {
87
+ if (typeof entry.timeoutMs === 'number') return entry.timeoutMs;
88
+ if (typeof entry.toolCallTimeoutMs === 'number') return entry.toolCallTimeoutMs;
89
+ if (typeof entry.timeout === 'number') {
90
+ // `timeout` is milliseconds in Gemini/Qwen, seconds in Roo/Codex.
91
+ return entry.timeout * (entry.__timeoutUnit === 's' ? 1000 : 1);
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Decode one raw client entry into the canonical model.
98
+ *
99
+ * `dialect` carries what the client means by ambiguous fields:
100
+ * { urlKey, typeMap, envKey, commandArray, timeoutUnit }
101
+ */
102
+ export function decodeEntry(name, entry, dialect = {}) {
103
+ const raw = asObject(entry);
104
+ const urlKey = dialect.urlKey ?? 'url';
105
+ const envKey = dialect.envKey ?? 'env';
106
+ const typeMap = dialect.typeMap ?? {};
107
+ const declaredType = typeof raw.type === 'string' ? raw.type.toLowerCase() : '';
108
+ const mapped = typeMap[declaredType] ?? null;
109
+
110
+ const url = raw[urlKey] ?? raw.url ?? raw.httpUrl ?? raw.serverUrl;
111
+ let transport = 'stdio';
112
+ if (mapped) transport = mapped;
113
+ else if (typeof url === 'string' && url) {
114
+ // No usable type tag: infer. `sse` hint wins, otherwise streamable HTTP.
115
+ const hint = (declaredType || String(raw.__sseHint ?? '')).toLowerCase();
116
+ transport = hint === 'sse' ? 'http-sse' : 'http-stream';
117
+ } else if (declaredType === 'sse') transport = 'http-sse';
118
+ else if (declaredType && ['http', 'streamable-http', 'streamablehttp', 'remote'].includes(declaredType)) transport = 'http-stream';
119
+ else if (['local', 'stdio'].includes(declaredType)) transport = 'stdio';
120
+ else if (raw.command !== undefined) transport = 'stdio';
121
+
122
+ const commandValue = raw.command;
123
+ const commandArray = Array.isArray(commandValue);
124
+ const commandParts = commandArray ? asStringArray(commandValue) : [String(commandValue ?? '')];
125
+ const command = commandParts[0] ?? '';
126
+ const args = commandArray ? commandParts.slice(1) : asStringArray(raw.args);
127
+
128
+ const env = {};
129
+ const headers = {};
130
+ for (const [key, value] of Object.entries(asObject(raw[envKey]))) env[key] = value;
131
+ for (const [key, value] of Object.entries(asObject(raw.headers))) headers[key] = value;
132
+
133
+ const extra = {};
134
+ for (const [key, value] of Object.entries(raw)) {
135
+ if (OWNED.has(key)) continue;
136
+ if (key === urlKey) continue;
137
+ extra[key] = value;
138
+ }
139
+
140
+ const enabled = raw.disabled === true ? false : raw.enabled === false ? false : true;
141
+ const autoApproveRaw = raw.autoApprove ?? raw.alwaysAllow;
142
+ const autoApprove = autoApproveRaw === true ? true : Array.isArray(autoApproveRaw) ? autoApproveRaw.map(String) : [];
143
+
144
+ /*
145
+ * Any credential-named field holding a literal is a finding; `literal`
146
+ * separates an opaque-looking value from a short one so the UI can rank them
147
+ * without either being hidden.
148
+ */
149
+ const secrets = [];
150
+ for (const [key, value] of Object.entries(env)) {
151
+ if (isSecretKey(key) && !isReferenceValue(value) && String(value ?? '').length > 0) secrets.push({ where: 'env', key, literal: isSecretValue(value) });
152
+ }
153
+ for (const [key, value] of Object.entries(headers)) {
154
+ if (isSecretKey(key) && !isReferenceValue(value) && String(value ?? '').length > 0) secrets.push({ where: 'header', key, literal: isSecretValue(value) });
155
+ }
156
+ for (const found of secretArgs(args)) {
157
+ if (!isReferenceValue(String(found.value))) secrets.push({ where: 'args', key: found.key, literal: isSecretValue(String(found.value)) });
158
+ }
159
+
160
+ return {
161
+ name,
162
+ transport,
163
+ command,
164
+ args,
165
+ env,
166
+ cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
167
+ url: typeof url === 'string' ? url : undefined,
168
+ headers,
169
+ timeoutMs: pickTimeout({ ...raw, __timeoutUnit: dialect.timeoutUnit }),
170
+ enabled,
171
+ autoApprove,
172
+ extra,
173
+ secrets,
174
+ declaredType: raw.type,
175
+ raw,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Encode the canonical model back into one client's entry shape, using the
181
+ * dialect's own spellings and units. Unknown fields in `extra` survive.
182
+ */
183
+ export function encodeEntry(server, dialect = {}, previous = undefined) {
184
+ const urlKey = dialect.urlKey ?? 'url';
185
+ const envKey = dialect.envKey ?? 'env';
186
+ const typeMap = dialect.typeMap ?? {};
187
+ const reverse = dialect.reverseType ?? {};
188
+ const out = { ...asObject(server.extra) };
189
+
190
+ if (server.transport === 'stdio') {
191
+ const parts = [server.command, ...(server.args ?? [])].filter((part) => part !== undefined && part !== '');
192
+ if (dialect.commandArray) out.command = parts;
193
+ else {
194
+ out.command = server.command ?? '';
195
+ if (server.args && server.args.length) out.args = [...server.args];
196
+ else delete out.args;
197
+ }
198
+ if (Object.keys(server.env ?? {}).length) out[envKey] = { ...server.env };
199
+ else delete out[envKey];
200
+ if (server.cwd) out.cwd = server.cwd;
201
+ else delete out.cwd;
202
+ delete out[urlKey];
203
+ if (urlKey !== 'url') delete out.url;
204
+ delete out.headers;
205
+ } else {
206
+ out[urlKey] = server.url ?? '';
207
+ if (Object.keys(server.headers ?? {}).length) out.headers = { ...server.headers };
208
+ else delete out.headers;
209
+ delete out.command;
210
+ delete out.args;
211
+ delete out[envKey];
212
+ if (envKey !== 'env') delete out.env;
213
+ delete out.cwd;
214
+ }
215
+
216
+ // `type`: only written where the client interprets it, with its own spelling.
217
+ const typeValue = reverse[server.transport];
218
+ if (typeValue !== undefined && typeValue !== null) out.type = typeValue;
219
+ else if (dialect.typeRequired) out.type = server.transport === 'stdio' ? (dialect.stdioType ?? 'stdio') : (typeMap.http ?? 'http');
220
+ else if (dialect.dropUnknownType) delete out.type;
221
+ else if (typeof server.declaredType === 'string' && !reverse[server.transport]) out.type = server.declaredType;
222
+ else delete out.type;
223
+
224
+ // Timeout in the client's own unit.
225
+ delete out.timeoutMs;
226
+ delete out.toolCallTimeoutMs;
227
+ if (typeof server.timeoutMs === 'number' && server.timeoutMs > 0) {
228
+ if (dialect.timeoutField) out[dialect.timeoutField] = dialect.timeoutUnit === 's' ? Math.round(server.timeoutMs / 1000) : server.timeoutMs;
229
+ else delete out.timeout;
230
+ } else {
231
+ delete out.timeout;
232
+ }
233
+
234
+ if (server.enabled === false) {
235
+ if (dialect.enabledField === 'enabled') out.enabled = false;
236
+ else out.disabled = true;
237
+ } else {
238
+ if (dialect.enabledField === 'enabled') { if (out.enabled === false) out.enabled = true; }
239
+ else delete out.disabled;
240
+ }
241
+
242
+ const autoField = dialect.autoApproveField;
243
+ if (autoField) {
244
+ const value = server.autoApprove;
245
+ if (value === true) out[autoField] = true;
246
+ else if (Array.isArray(value) && value.length) out[autoField] = [...value];
247
+ else delete out[autoField];
248
+ }
249
+
250
+ // Keep undefined out of the serialized form.
251
+ for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key];
252
+ return out;
253
+ }
254
+
255
+ /* --------------------------------------------------------------- plumbing -- */
256
+
257
+ const NAME_SAFE = /[^A-Za-z0-9_-]+/g;
258
+
259
+ /** Sanitize an external server name into `[A-Za-z0-9_-]{1,32}`. */
260
+ export function sanitizeName(raw, fallback = 'server') {
261
+ const cleaned = String(raw ?? '').trim().replace(NAME_SAFE, '-').replace(/^-+|-+$/g, '').slice(0, 32);
262
+ return cleaned || fallback;
263
+ }
264
+
265
+ /**
266
+ * The `serverName` to mount a server under inside DeepSeek Harness.
267
+ *
268
+ * The harness's own `@deepseek-ai/dsh-mcp-client` constrains `serverName` to
269
+ * `/^[A-Za-z0-9_-]{1,32}$/` (lib/index.js of that package, the zod schema and
270
+ * `SERVER_NAME_PATTERN`), so a name like `表单管理系统 - API 文档` CANNOT be used
271
+ * verbatim. It has to be mapped.
272
+ *
273
+ * `sanitizeName` alone is not a usable mapping here: every CJK name collapses
274
+ * to whatever ASCII fragments it contains — `表单管理系统 - API 文档`,
275
+ * `方案生成助手 - API 文档` and `心理健康关爱系统 - API 文档` ALL become `API`, and
276
+ * for a fully-CJK name it falls back to the constant `server`. That collision is
277
+ * not cosmetic. It produced two visible bugs: the mount row was created under
278
+ * `API`, the page looked the mount up by the display name (found nothing), so
279
+ * the switch showed "off" for a mounted server — and `waitForRow` waited for a
280
+ * row named after the display name, never saw it, and told the user to restart.
281
+ *
282
+ * So there are two shapes, both pure functions of the display name:
283
+ *
284
+ * 1. An all-ASCII name keeps `sanitizeName`'s result unchanged. Those names
285
+ * are already readable and already distinct from each other, so
286
+ * `Figma AI Bridge` stays `Figma-AI-Bridge` and `my server!` stays
287
+ * `my-server` — which also means rows mounted before this mapping existed
288
+ * keep their identity.
289
+ * 2. A name with any non-ASCII character — CJK, full-width punctuation,
290
+ * emoji — is exactly the case that collapses under `sanitizeName`, so it
291
+ * gets a short deterministic suffix derived from the ORIGINAL name.
292
+ * `fingerprint` is the FNV-1a helper in this module, so the same display
293
+ * name always yields the same mount name (a repeat apply updates the row
294
+ * instead of accumulating one per click) while two different CJK names
295
+ * cannot collide.
296
+ */
297
+ export function dshMountName(raw) {
298
+ const original = String(raw ?? '').trim();
299
+ if (!original) return 'server';
300
+ if (/^[A-Za-z0-9_-]{1,32}$/.test(original)) return original;
301
+ const base = sanitizeName(original, '');
302
+ if (/^[\x20-\x7E]+$/.test(original) && base) return base;
303
+ return `${(base || 'mcp').slice(0, 22)}-${fingerprint(original).slice(0, 8)}`;
304
+ }
305
+
306
+ /** Return `name`, or `name-2`/`name-3`… when taken. */
307
+ export function uniqueName(name, taken) {
308
+ if (!taken.has(name)) return name;
309
+ for (let index = 2; index < 1000; index += 1) {
310
+ const candidate = `${name.slice(0, 28)}-${index}`;
311
+ if (!taken.has(candidate)) return candidate;
312
+ }
313
+ throw new Error(`cannot derive a unique name for ${name}`);
314
+ }
315
+
316
+ /** Deterministic short fingerprint for change detection. */
317
+ export function fingerprint(value) {
318
+ const text = stableStringify(value ?? null);
319
+ let hash = 0x811c9dc5;
320
+ for (let i = 0; i < text.length; i += 1) {
321
+ hash ^= text.charCodeAt(i);
322
+ hash = (hash * 0x01000193) >>> 0;
323
+ }
324
+ return hash.toString(16).padStart(8, '0');
325
+ }
326
+
327
+ /** JSON with object keys sorted, so equal objects always hash equally. */
328
+ function stableStringify(value) {
329
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
330
+ if (value && typeof value === 'object') {
331
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
332
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
333
+ }
334
+ return JSON.stringify(value ?? null);
335
+ }
336
+
337
+ /**
338
+ * The fields that define a server, exactly as `sameDefinition` compares them.
339
+ * One normalizer for both hashing and equality keeps the ledger, the scan and
340
+ * the write guard from disagreeing about what "the same entry" means.
341
+ */
342
+ function definitionShape(server) {
343
+ return {
344
+ transport: server?.transport ?? 'stdio',
345
+ command: server?.command ?? null,
346
+ args: server?.args ?? [],
347
+ env: server?.env ?? {},
348
+ cwd: server?.cwd ?? null,
349
+ url: server?.url ?? null,
350
+ headers: server?.headers ?? {},
351
+ timeoutMs: server?.timeoutMs ?? null,
352
+ autoApprove: server?.autoApprove ?? [],
353
+ enabled: server?.enabled !== false,
354
+ extra: server?.extra ?? {},
355
+ };
356
+ }
357
+
358
+ /** Same definition, semantically. */
359
+ export function sameDefinition(left, right) {
360
+ return stableStringify(definitionShape(left)) === stableStringify(definitionShape(right));
361
+ }
362
+
363
+ /** Stable identity of a definition, for the ownership ledger and drift checks. */
364
+ export function definitionHash(server) {
365
+ return fingerprint(definitionShape(server));
366
+ }
367
+
368
+ /**
369
+ * Compare two canonical servers field by field, so the UI can show what a sync
370
+ * would actually change instead of a bare "differs".
371
+ */
372
+ export function diffServers(left, right) {
373
+ const fields = ['transport', 'command', 'args', 'env', 'cwd', 'url', 'headers', 'timeoutMs', 'enabled'];
374
+ const changed = [];
375
+ for (const field of fields) {
376
+ const a = JSON.stringify(left?.[field] ?? null);
377
+ const b = JSON.stringify(right?.[field] ?? null);
378
+ if (a !== b) changed.push({ field, from: left?.[field] ?? null, to: right?.[field] ?? null });
379
+ }
380
+ return changed;
381
+ }
382
+
383
+ /**
384
+ * Match an argument that carries a credential, in either of the two shapes
385
+ * CLIs accept:
386
+ *
387
+ * --figma-api-key=figd_xxx (inline value)
388
+ * --api-key figd_xxx (separate value — the *next* argument)
389
+ *
390
+ * The command line is a first-class place for secrets: `npx figma-developer-mcp
391
+ * --figma-api-key=…` is the documented invocation, so masking env and headers
392
+ * alone leaves real tokens in the payload.
393
+ */
394
+ const SECRET_ARG_INLINE = /^(--?[A-Za-z0-9_.-]*(?:token|key|secret|password|passwd|credential|auth|cookie|session|private)[A-Za-z0-9_.-]*)=(.*)$/i;
395
+ const SECRET_ARG_FLAG = /^--?[A-Za-z0-9_.-]*(?:token|key|secret|password|passwd|credential|auth|cookie|session|private)[A-Za-z0-9_.-]*$/i;
396
+
397
+ /** Find credential-bearing arguments; returns [{index, key, value}] for masking. */
398
+ export function secretArgs(args) {
399
+ const out = [];
400
+ const list = Array.isArray(args) ? args : [];
401
+ for (let index = 0; index < list.length; index += 1) {
402
+ const text = String(list[index] ?? '');
403
+ const inline = text.match(SECRET_ARG_INLINE);
404
+ if (inline) { out.push({ index, key: inline[1], value: inline[2] }); continue; }
405
+ if (SECRET_ARG_FLAG.test(text)) {
406
+ const next = list[index + 1];
407
+ if (typeof next === 'string' && next && !next.startsWith('-')) out.push({ index: index + 1, key: text, value: next });
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+
413
+ /** Mask every credential-bearing argument, leaving the flag visible. */
414
+ export function redactArgs(args) {
415
+ const list = Array.isArray(args) ? [...args] : [];
416
+ for (const found of secretArgs(list)) {
417
+ const token = String(found.value ?? '');
418
+ if (!token || isReferenceValue(token)) continue;
419
+ const masked = maskValue(token);
420
+ const current = String(list[found.index] ?? '');
421
+ /*
422
+ * When the secret shared an argument with its flag, keep the flag: mask the
423
+ * value portion only, otherwise the argument no longer reads as a flag.
424
+ */
425
+ list[found.index] = current.startsWith(`${found.key}=`) ? `${found.key}=${masked}` : masked;
426
+ }
427
+ return list;
428
+ }
429
+
430
+ /**
431
+ * Strip credential literals before a server object crosses to the browser.
432
+ *
433
+ * Redaction covers every place a literal can hide, not just the two maps the
434
+ * canonical model owns: `args` is the documented home of `--api-key=…` flags,
435
+ * `raw` holds a full copy of the original entry, and `extra` holds unmodelled
436
+ * client options, so a token in an unusual key would otherwise ride along
437
+ * inside the scan payload and into the model's context. Masking is
438
+ * depth-limited and cycle-safe because `raw` is client JSON, which has been
439
+ * known to nest arbitrarily.
440
+ */
441
+ export function redactServer(server, reveal = false) {
442
+ if (reveal || !server) return server;
443
+
444
+ const seen = new WeakSet();
445
+ const scrub = (value, keyHint, depth) => {
446
+ if (depth > 8) return '…';
447
+ if (typeof value === 'string') {
448
+ /*
449
+ * Key-driven masking: a credential-named field is masked whatever its
450
+ * length, except when the value is an indirection that only names the
451
+ * secret. Masking on value shape alone would leak short tokens.
452
+ */
453
+ return keyHint && isSecretKey(keyHint) && !isReferenceValue(value) ? maskValue(value) : value;
454
+ }
455
+ if (Array.isArray(value)) {
456
+ /*
457
+ * A nested argument list obeys the same flag rule as the top-level one;
458
+ * `raw` carries the client's own copy of `args`, so masking only the
459
+ * modelled field would still ship the literal inside it.
460
+ */
461
+ if (keyHint === 'args') return redactArgs(value);
462
+ return value.map((item) => scrub(item, keyHint, depth + 1));
463
+ }
464
+ if (value && typeof value === 'object') {
465
+ if (seen.has(value)) return '[circular]';
466
+ seen.add(value);
467
+ const out = {};
468
+ for (const [key, inner] of Object.entries(value)) out[key] = scrub(inner, key, depth + 1);
469
+ seen.delete(value);
470
+ return out;
471
+ }
472
+ return value;
473
+ };
474
+
475
+ const out = {};
476
+ for (const [key, value] of Object.entries(server)) {
477
+ if (key === 'env' || key === 'headers' || key === 'extra' || key === 'raw') {
478
+ out[key] = scrub(value, null, 0);
479
+ continue;
480
+ }
481
+ if (key === 'args') {
482
+ out[key] = redactArgs(value);
483
+ continue;
484
+ }
485
+ out[key] = value;
486
+ }
487
+ return out;
488
+ }