dsh-mcp 1.0.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 ADDED
@@ -0,0 +1,894 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
+ import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
5
+ import { probeConnection } from "./probe.js";
6
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
7
+ import z from "@deepseek-ai/schemastery";
8
+ import { z as z$1 } from "zod";
9
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
10
+ //#region lib/types/spec.js
11
+ /**
12
+ * Durable storage-domain declaration and wire-boundary validation for managed
13
+ * MCP server definitions. Record schemas are zod (the domain layer's language);
14
+ * request validation reuses the same field schemas so the durable and wire
15
+ * boundaries cannot drift.
16
+ * @module @deepseek-ai/dsh-mcp-manager/src/spec
17
+ */
18
+ /** MCP tool namespace: the same contract mcp-client enforces. */
19
+ const serverNameSchema = z$1.string().regex(/^[A-Za-z0-9_-]{1,32}$/, { message: "serverName must match [A-Za-z0-9_-]{1,32}" });
20
+ /** POSIX shell identifier, the shape of an injected environment variable. */
21
+ const envVarNameSchema = z$1.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { message: "environment variable name must be a POSIX identifier" });
22
+ const positiveIntegerSchema = z$1.number().int().positive().max(Number.MAX_SAFE_INTEGER);
23
+ const headerSchema = z$1.object({
24
+ name: z$1.string().min(1),
25
+ value: z$1.string()
26
+ });
27
+ const envEntrySchema = z$1.discriminatedUnion("secret", [z$1.object({
28
+ name: envVarNameSchema,
29
+ secret: z$1.literal(false),
30
+ value: z$1.string().default("")
31
+ }), z$1.object({
32
+ name: envVarNameSchema,
33
+ secret: z$1.literal(true),
34
+ value: z$1.string().optional()
35
+ })]);
36
+ /** Durable sidecar record per server id. */
37
+ const mcpServersDomainSpec = defineDomain({
38
+ name: "mcp_servers",
39
+ version: 0,
40
+ tables: { servers: domainTable(z$1.object({
41
+ id: z$1.string().min(1).transform((value) => value),
42
+ serverName: serverNameSchema,
43
+ transport: z$1.union([z$1.literal("stdio"), z$1.literal("streamable-http")]),
44
+ enabled: z$1.boolean(),
45
+ command: z$1.string(),
46
+ args: z$1.array(z$1.string()).default([]),
47
+ cwd: z$1.string().default(""),
48
+ url: z$1.string(),
49
+ headers: z$1.array(headerSchema).default([]),
50
+ env: z$1.array(envEntrySchema).default([]),
51
+ toolCallTimeoutMs: positiveIntegerSchema,
52
+ failOnStartupError: z$1.boolean().default(false)
53
+ }).superRefine((row, ctx) => {
54
+ if (row.transport === "stdio" && row.command.trim().length === 0) ctx.addIssue({
55
+ code: "custom",
56
+ path: ["command"],
57
+ message: "stdio transport requires a command"
58
+ });
59
+ if (row.transport === "streamable-http") try {
60
+ const parsed = new URL(row.url);
61
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("non-http protocol");
62
+ } catch {
63
+ ctx.addIssue({
64
+ code: "custom",
65
+ path: ["url"],
66
+ message: "streamable-http requires an absolute http(s) URL"
67
+ });
68
+ }
69
+ const headerNames = /* @__PURE__ */ new Set();
70
+ row.headers.forEach((header, index) => {
71
+ if (headerNames.has(header.name)) ctx.addIssue({
72
+ code: "custom",
73
+ path: [
74
+ "headers",
75
+ index,
76
+ "name"
77
+ ],
78
+ message: `duplicate header '${header.name}'`
79
+ });
80
+ headerNames.add(header.name);
81
+ });
82
+ const envNames = /* @__PURE__ */ new Set();
83
+ row.env.forEach((entry, index) => {
84
+ if (envNames.has(entry.name)) ctx.addIssue({
85
+ code: "custom",
86
+ path: [
87
+ "env",
88
+ index,
89
+ "name"
90
+ ],
91
+ message: `duplicate environment variable '${entry.name}'`
92
+ });
93
+ envNames.add(entry.name);
94
+ });
95
+ })) }
96
+ });
97
+ /** Thrown by {@link validateServerInput}; the service maps it to `MCP_INVALID_SPEC`. */
98
+ var McpServerValidationError = class extends Error {
99
+ /** Discriminates validation failures from internal errors at the Remote boundary. */
100
+ code = "MCP_INVALID_SPEC";
101
+ /**
102
+ * @param message - Human-readable reason safe to render in a management UI.
103
+ */
104
+ constructor(message) {
105
+ super(message);
106
+ this.name = "McpServerValidationError";
107
+ }
108
+ };
109
+ /**
110
+ * Validate one upsert/test request at the wire boundary.
111
+ * @param server - The submitted definition.
112
+ * @param env - The submitted env rows (including values to store).
113
+ * @throws {@link McpServerValidationError} with a readable reason.
114
+ */
115
+ function validateServerInput(server, env) {
116
+ if (!serverNameSchema.safeParse(server.serverName).success) throw new McpServerValidationError("serverName must match [A-Za-z0-9_-]{1,32}");
117
+ if (server.transport === "stdio") {
118
+ if (server.command.trim().length === 0) throw new McpServerValidationError("stdio transport requires a command");
119
+ if (server.cwd.length > 0 && !/^[/\\]/.test(server.cwd)) throw new McpServerValidationError("cwd must be an absolute path or empty");
120
+ } else try {
121
+ const parsed = new URL(server.url);
122
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("non-http protocol");
123
+ } catch {
124
+ throw new McpServerValidationError("streamable-http requires an absolute http(s) URL");
125
+ }
126
+ if (!Number.isInteger(server.toolCallTimeoutMs) || server.toolCallTimeoutMs <= 0) throw new McpServerValidationError("toolCallTimeoutMs must be a positive integer");
127
+ const headerNames = /* @__PURE__ */ new Set();
128
+ for (const header of server.headers) {
129
+ if (header.name.trim().length === 0) throw new McpServerValidationError("header names must not be blank");
130
+ if (headerNames.has(header.name)) throw new McpServerValidationError(`duplicate header '${header.name}'`);
131
+ headerNames.add(header.name);
132
+ }
133
+ const envNames = /* @__PURE__ */ new Set();
134
+ for (const entry of env) {
135
+ if (!envVarNameSchema.safeParse(entry.name).success) throw new McpServerValidationError(`environment variable name '${entry.name}' must be a POSIX identifier`);
136
+ if (envNames.has(entry.name)) throw new McpServerValidationError(`duplicate environment variable '${entry.name}'`);
137
+ envNames.add(entry.name);
138
+ }
139
+ }
140
+ /**
141
+ * Build the durable row for one request, stripping secret values so they can
142
+ * only ever live in the credentials document.
143
+ * @param id - Server id (existing or freshly minted).
144
+ * @param server - The validated definition.
145
+ * @param env - The validated env rows.
146
+ * @returns The row to persist.
147
+ */
148
+ function toServerRow(id, server, env) {
149
+ return {
150
+ id,
151
+ serverName: server.serverName,
152
+ transport: server.transport,
153
+ enabled: server.enabled,
154
+ command: server.command,
155
+ args: [...server.args],
156
+ cwd: server.cwd,
157
+ url: server.url,
158
+ headers: server.headers.map((header) => ({
159
+ name: header.name,
160
+ value: header.value
161
+ })),
162
+ env: env.map((entry) => entry.secret ? {
163
+ name: entry.name,
164
+ secret: true
165
+ } : {
166
+ name: entry.name,
167
+ secret: false,
168
+ value: entry.value ?? ""
169
+ }),
170
+ toolCallTimeoutMs: server.toolCallTimeoutMs,
171
+ failOnStartupError: server.failOnStartupError
172
+ };
173
+ }
174
+ //#endregion
175
+ //#region lib/types/index.js
176
+ /**
177
+ * MCP manager service: owns persisted MCP server definitions, mounts one
178
+ * `mcp-client` instance per enabled server at runtime, injects each server's
179
+ * environment variables (plain values from the definition, secrets from the
180
+ * credentials document) into its stdio child, and exposes list/upsert/remove/
181
+ * test to the browser through the `mcpManager` Remote namespace.
182
+ *
183
+ * Lifecycle: a definition change reconciles the live mount without a Host
184
+ * restart; a secret written through any surface restarts the affected server
185
+ * so the new value reaches the next spawned child.
186
+ * @module @deepseek-ai/dsh-mcp-manager
187
+ */
188
+ var __runInitializers = function(thisArg, initializers, value) {
189
+ var useValue = arguments.length > 2;
190
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
191
+ return useValue ? value : void 0;
192
+ };
193
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
194
+ function accept(f) {
195
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
196
+ return f;
197
+ }
198
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
199
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
200
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
201
+ var _, done = false;
202
+ for (var i = decorators.length - 1; i >= 0; i--) {
203
+ var context = {};
204
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
205
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
206
+ context.addInitializer = function(f) {
207
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
208
+ extraInitializers.push(accept(f || null));
209
+ };
210
+ var result = (0, decorators[i])(kind === "accessor" ? {
211
+ get: descriptor.get,
212
+ set: descriptor.set
213
+ } : descriptor[key], context);
214
+ if (kind === "accessor") {
215
+ if (result === void 0) continue;
216
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
217
+ if (_ = accept(result.get)) descriptor.get = _;
218
+ if (_ = accept(result.set)) descriptor.set = _;
219
+ if (_ = accept(result.init)) initializers.unshift(_);
220
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
221
+ else descriptor[key] = _;
222
+ }
223
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
224
+ done = true;
225
+ };
226
+ /** Credential reference namespace prefix for secret env values. */
227
+ const SECRET_REF_PREFIX = "DSH_MCP_";
228
+ const Config = z.object({ probeTimeoutMs: z.number().min(1).default(15e3) });
229
+ /** The mcp-client plugin as a Cordis object plugin, mounted per server. */
230
+ const MCP_CLIENT_PLUGIN = {
231
+ name: mcpClient.name,
232
+ inject: mcpClient.inject,
233
+ Config: mcpClient.Config,
234
+ apply: mcpClient.apply
235
+ };
236
+ /** Keep a thrown value readable for Remote failures and logs. */
237
+ function errorText(error) {
238
+ return error instanceof Error ? error.message : String(error);
239
+ }
240
+ /**
241
+ * The managed server id a credential reference belongs to, when the reference
242
+ * is one of this manager's secret env refs.
243
+ * @param ref - A credential reference.
244
+ * @returns the server id, or undefined when the ref is not managed.
245
+ */
246
+ function managedServerId(ref) {
247
+ if (!ref.startsWith(SECRET_REF_PREFIX)) return void 0;
248
+ const rest = ref.slice(8);
249
+ const separator = rest.lastIndexOf("_");
250
+ if (separator <= 0) return void 0;
251
+ return rest.slice(0, separator);
252
+ }
253
+ /** Credential reference holding one secret env value. */
254
+ function secretRef(id, name) {
255
+ return credentialRef(`${SECRET_REF_PREFIX}${id}_${name}`);
256
+ }
257
+ /** Brand a fresh random server id at the minting boundary. */
258
+ function mintServerId(existing) {
259
+ for (let attempt = 0; attempt < 10; attempt += 1) {
260
+ const id = `mcp_${randomBytes(6).toString("hex")}`;
261
+ if (!existing.has(id)) return id;
262
+ }
263
+ throw new Error("mcp-manager: failed to mint a unique server id");
264
+ }
265
+ /**
266
+ * The managed MCP server registry, exposed to the browser as the `mcpManager`
267
+ * Remote namespace.
268
+ */
269
+ let McpManagerService = (() => {
270
+ let _classSuper = TypertRemoteService;
271
+ let _instanceExtraInitializers = [];
272
+ let _list_decorators;
273
+ let _upsert_decorators;
274
+ let _delete_decorators;
275
+ let _test_decorators;
276
+ let _toolsList_decorators;
277
+ let _toolsSet_decorators;
278
+ let _toolsMode_decorators;
279
+ return class McpManagerService extends _classSuper {
280
+ static {
281
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
282
+ _list_decorators = [Remote("list")];
283
+ _upsert_decorators = [Remote("upsert")];
284
+ _delete_decorators = [Remote("delete")];
285
+ _test_decorators = [Remote("test")];
286
+ _toolsList_decorators = [Remote("toolsList")];
287
+ _toolsSet_decorators = [Remote("toolsSet")];
288
+ _toolsMode_decorators = [Remote("toolsMode")];
289
+ __esDecorate(this, null, _list_decorators, {
290
+ kind: "method",
291
+ name: "list",
292
+ static: false,
293
+ private: false,
294
+ access: {
295
+ has: (obj) => "list" in obj,
296
+ get: (obj) => obj.list
297
+ },
298
+ metadata: _metadata
299
+ }, null, _instanceExtraInitializers);
300
+ __esDecorate(this, null, _upsert_decorators, {
301
+ kind: "method",
302
+ name: "upsert",
303
+ static: false,
304
+ private: false,
305
+ access: {
306
+ has: (obj) => "upsert" in obj,
307
+ get: (obj) => obj.upsert
308
+ },
309
+ metadata: _metadata
310
+ }, null, _instanceExtraInitializers);
311
+ __esDecorate(this, null, _delete_decorators, {
312
+ kind: "method",
313
+ name: "delete",
314
+ static: false,
315
+ private: false,
316
+ access: {
317
+ has: (obj) => "delete" in obj,
318
+ get: (obj) => obj.delete
319
+ },
320
+ metadata: _metadata
321
+ }, null, _instanceExtraInitializers);
322
+ __esDecorate(this, null, _test_decorators, {
323
+ kind: "method",
324
+ name: "test",
325
+ static: false,
326
+ private: false,
327
+ access: {
328
+ has: (obj) => "test" in obj,
329
+ get: (obj) => obj.test
330
+ },
331
+ metadata: _metadata
332
+ }, null, _instanceExtraInitializers);
333
+ __esDecorate(this, null, _toolsList_decorators, {
334
+ kind: "method",
335
+ name: "toolsList",
336
+ static: false,
337
+ private: false,
338
+ access: {
339
+ has: (obj) => "toolsList" in obj,
340
+ get: (obj) => obj.toolsList
341
+ },
342
+ metadata: _metadata
343
+ }, null, _instanceExtraInitializers);
344
+ __esDecorate(this, null, _toolsSet_decorators, {
345
+ kind: "method",
346
+ name: "toolsSet",
347
+ static: false,
348
+ private: false,
349
+ access: {
350
+ has: (obj) => "toolsSet" in obj,
351
+ get: (obj) => obj.toolsSet
352
+ },
353
+ metadata: _metadata
354
+ }, null, _instanceExtraInitializers);
355
+ __esDecorate(this, null, _toolsMode_decorators, {
356
+ kind: "method",
357
+ name: "toolsMode",
358
+ static: false,
359
+ private: false,
360
+ access: {
361
+ has: (obj) => "toolsMode" in obj,
362
+ get: (obj) => obj.toolsMode
363
+ },
364
+ metadata: _metadata
365
+ }, null, _instanceExtraInitializers);
366
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
367
+ enumerable: true,
368
+ configurable: true,
369
+ writable: true,
370
+ value: _metadata
371
+ });
372
+ }
373
+ static inject = [
374
+ "storageDomain",
375
+ "credentials",
376
+ "tools"
377
+ ];
378
+ static Config = Config;
379
+ table = __runInitializers(this, _instanceExtraInitializers);
380
+ /** Live mcp-client mounts keyed by server id. */
381
+ mounts = /* @__PURE__ */ new Map();
382
+ /** Per-id operation chain serializing reconcile, remove, and restart jobs. */
383
+ operationTails = /* @__PURE__ */ new Map();
384
+ /** Server ids whose secret writes are this manager's own; skip restart echoes. */
385
+ suppressRestart = /* @__PURE__ */ new Set();
386
+ probeTimeoutMs;
387
+ /** Per-tool enable switches: a stored `false` disables that tool; absent means enabled. */
388
+ toolSwitches = /* @__PURE__ */ new Map();
389
+ /** Injection mode: `search` (default) injects only resident + hot tools; `full` injects every enabled tool. */
390
+ toolMode = "search";
391
+ /** Recently searched or called MCP tools, LRU-bounded; injected under search mode. */
392
+ hotTools = /* @__PURE__ */ new Map();
393
+ /** LRU bound for {@link hotTools}. */
394
+ static HOT_LIMIT = 60;
395
+ /**
396
+ * @param ctx - Host context carrying storage, credentials, and the tool registry.
397
+ * @param config - Resolved manager configuration.
398
+ */
399
+ constructor(ctx, config) {
400
+ super(ctx, "mcpManager");
401
+ this.probeTimeoutMs = config.probeTimeoutMs;
402
+ }
403
+ /** Open the storage domain, mount stored servers, and subscribe to secret changes. */
404
+ async [Service.init]() {
405
+ const domain = await this.ctx.storageDomain.open(mcpServersDomainSpec);
406
+ this.ctx.effect(() => async () => {
407
+ await this.teardownAll();
408
+ await domain.close();
409
+ }, "mcp-manager.domainClose");
410
+ this.table = domain.table("servers");
411
+ for (const [id, row] of this.table.entries()) if (row.enabled) this.mount(id, row);
412
+ this.installToolControl();
413
+ this.ctx.on("credentials/updated", (ref) => {
414
+ const id = managedServerId(ref);
415
+ if (id === void 0 || this.suppressRestart.has(id)) return;
416
+ this.serialize(id, async () => {
417
+ if (this.requireTable().get(id) !== void 0) await this.reconcileMount(id);
418
+ });
419
+ });
420
+ }
421
+ /** Queue one job on a server's own operation chain. */
422
+ serialize(id, job) {
423
+ const run = (this.operationTails.get(id) ?? Promise.resolve()).then(job);
424
+ this.operationTails.set(id, run.then(() => {}, () => {}));
425
+ return run;
426
+ }
427
+ /** Resolve the table after the domain is open; a missing table is a boot bug. */
428
+ requireTable() {
429
+ if (this.table === void 0) throw new Error("mcp-manager: storage domain is not open");
430
+ return this.table;
431
+ }
432
+ /** Resolve the full env map for one row: plain values plus credentials. */
433
+ async resolveEnv(row) {
434
+ const env = {};
435
+ for (const entry of row.env) if (entry.secret) {
436
+ const hit = await this.ctx.credentials.resolve(secretRef(row.id, entry.name));
437
+ if (hit !== void 0) env[entry.name] = hit.value;
438
+ } else if (entry.value !== void 0) env[entry.name] = entry.value;
439
+ return env;
440
+ }
441
+ /** Map a server spec + resolved env to the mcp-client plugin config. */
442
+ toClientConfig(spec, env) {
443
+ if (spec.transport === "stdio") return {
444
+ transport: "stdio",
445
+ serverName: spec.serverName,
446
+ command: spec.command,
447
+ args: [...spec.args],
448
+ cwd: spec.cwd,
449
+ env,
450
+ toolCallTimeoutMs: spec.toolCallTimeoutMs,
451
+ failOnStartupError: spec.failOnStartupError
452
+ };
453
+ return {
454
+ transport: "streamable-http",
455
+ serverName: spec.serverName,
456
+ url: spec.url,
457
+ headers: Object.fromEntries(spec.headers.map((header) => [header.name, header.value])),
458
+ toolCallTimeoutMs: spec.toolCallTimeoutMs,
459
+ failOnStartupError: spec.failOnStartupError
460
+ };
461
+ }
462
+ /**
463
+ * Mount one server's mcp-client instance without awaiting activation, so a
464
+ * hung server cannot block a Remote call. The mount entry's phase flips via
465
+ * the fiber settlement callbacks.
466
+ * @param id - Server id.
467
+ * @param row - The stored definition.
468
+ */
469
+ mount(id, row) {
470
+ this.serialize(id, async () => {
471
+ await this.disposeMount(id);
472
+ const entry = {
473
+ phase: "mounting",
474
+ error: void 0
475
+ };
476
+ this.mounts.set(id, entry);
477
+ let handle;
478
+ try {
479
+ const env = await this.resolveEnv(row);
480
+ handle = this.ctx.plugin(MCP_CLIENT_PLUGIN, this.toClientConfig(row, env));
481
+ } catch (error) {
482
+ entry.phase = "failed";
483
+ entry.error = errorText(error);
484
+ this.ctx.logger.error(`mcp-manager(${id}): mount failed: ${entry.error}`);
485
+ return;
486
+ }
487
+ entry.handle = handle;
488
+ handle.await().then(() => {
489
+ entry.phase = "live";
490
+ entry.error = void 0;
491
+ }, (error) => {
492
+ entry.phase = "failed";
493
+ entry.error = errorText(error);
494
+ this.ctx.logger.error(`mcp-manager(${id}): mount failed: ${entry.error}`);
495
+ });
496
+ });
497
+ }
498
+ /** Stop and forget one live mount. */
499
+ async disposeMount(id) {
500
+ const entry = this.mounts.get(id);
501
+ if (entry === void 0) return;
502
+ this.mounts.delete(id);
503
+ if (entry.handle !== void 0) await entry.handle.dispose();
504
+ }
505
+ /** Dispose every live mount (domain teardown). */
506
+ async teardownAll() {
507
+ const ids = [...this.mounts.keys()];
508
+ for (const id of ids) await this.disposeMount(id);
509
+ }
510
+ /** Stop the current mount and start one from the stored row, when enabled. */
511
+ async reconcileMount(id) {
512
+ const row = this.requireTable().get(id);
513
+ if (row === void 0 || !row.enabled) {
514
+ await this.disposeMount(id);
515
+ return;
516
+ }
517
+ await this.disposeMount(id);
518
+ this.mount(id, row);
519
+ }
520
+ /** Build the client-facing projection of one stored row. */
521
+ async view(id, row) {
522
+ const mount = this.mounts.get(id);
523
+ const prefix = `mcp__${row.serverName}__`;
524
+ const tools = this.ctx.tools.schemas().map((schema) => schema.name).filter((name) => name.startsWith(prefix));
525
+ const env = [];
526
+ for (const entry of row.env) {
527
+ const configured = entry.secret ? (await this.ctx.credentials.describe(secretRef(id, entry.name))).configured : (entry.value ?? "").length > 0;
528
+ env.push({
529
+ name: entry.name,
530
+ secret: entry.secret,
531
+ configured
532
+ });
533
+ }
534
+ return {
535
+ id,
536
+ serverName: row.serverName,
537
+ transport: row.transport,
538
+ enabled: row.enabled,
539
+ command: row.command,
540
+ args: [...row.args],
541
+ cwd: row.cwd,
542
+ url: row.url,
543
+ headers: row.headers.map((header) => ({
544
+ name: header.name,
545
+ value: header.value
546
+ })),
547
+ env,
548
+ toolCallTimeoutMs: row.toolCallTimeoutMs,
549
+ failOnStartupError: row.failOnStartupError,
550
+ status: {
551
+ phase: mount?.phase ?? "stopped",
552
+ tools,
553
+ ...mount?.error === void 0 ? {} : { error: mount.error }
554
+ }
555
+ };
556
+ }
557
+ /** A not-found failure for one id. */
558
+ notFound(id) {
559
+ return {
560
+ code: "MCP_SERVER_NOT_FOUND",
561
+ message: `no managed MCP server with id "${id}"`
562
+ };
563
+ }
564
+ /**
565
+ * Read every stored definition with its live status.
566
+ * @returns the current server list.
567
+ */
568
+ async list() {
569
+ const table = this.requireTable();
570
+ const servers = [];
571
+ for (const [id, row] of table.entries()) servers.push(await this.view(id, row));
572
+ return {
573
+ ok: true,
574
+ servers
575
+ };
576
+ }
577
+ /**
578
+ * Create or replace one server definition and reconcile its live mount.
579
+ * Secret env values are written to the credentials document; a secret entry
580
+ * with no submitted value keeps the stored one.
581
+ * @param request - Server id (existing) or absent (create) plus the definition and env rows.
582
+ * @returns the updated server view or an explicit failure.
583
+ */
584
+ async upsert(request) {
585
+ const table = this.requireTable();
586
+ try {
587
+ validateServerInput(request.server, request.env);
588
+ } catch (error) {
589
+ if (error instanceof McpServerValidationError) return {
590
+ ok: false,
591
+ error: {
592
+ code: "MCP_INVALID_SPEC",
593
+ message: error.message
594
+ }
595
+ };
596
+ throw error;
597
+ }
598
+ if (request.id !== void 0 && table.get(request.id) === void 0) return {
599
+ ok: false,
600
+ error: this.notFound(request.id)
601
+ };
602
+ const id = request.id ?? mintServerId(new Set(table.keys()));
603
+ for (const [otherId, row] of table.entries()) if (otherId !== id && row.serverName === request.server.serverName) return {
604
+ ok: false,
605
+ error: {
606
+ code: "MCP_SERVER_NAME_CONFLICT",
607
+ message: `serverName "${request.server.serverName}" is already used by another managed server`
608
+ }
609
+ };
610
+ const previous = table.get(id);
611
+ await this.applyEnv(id, previous, request.env);
612
+ const row = toServerRow(id, request.server, request.env);
613
+ await table.put(id, row);
614
+ await this.serialize(id, () => this.reconcileMount(id));
615
+ return {
616
+ ok: true,
617
+ server: await this.view(id, row)
618
+ };
619
+ }
620
+ /**
621
+ * Persist env values for one server: set new secret values (suppressing the
622
+ * restart echo of our own writes), unset secrets whose rows were removed.
623
+ * @param id - Server id.
624
+ * @param previous - Previously stored row, when one exists.
625
+ * @param inputs - The submitted env rows.
626
+ */
627
+ async applyEnv(id, previous, inputs) {
628
+ const previousSecrets = new Set((previous?.env ?? []).filter((entry) => entry.secret).map((entry) => entry.name));
629
+ const currentSecrets = new Set(inputs.filter((entry) => entry.secret).map((entry) => entry.name));
630
+ for (const name of previousSecrets) if (!currentSecrets.has(name)) await this.ctx.credentials.unset(secretRef(id, name));
631
+ this.suppressRestart.add(id);
632
+ try {
633
+ for (const entry of inputs) {
634
+ if (!entry.secret || entry.value === void 0 || entry.value.length === 0) continue;
635
+ await this.ctx.credentials.set(secretRef(id, entry.name), entry.value);
636
+ }
637
+ } finally {
638
+ this.suppressRestart.delete(id);
639
+ }
640
+ }
641
+ /**
642
+ * Delete one server definition, stop its mount, and unset its secret refs.
643
+ * Named `delete` (wire `mcpManager/delete`): the Remote namespace service
644
+ * base class already owns a `remove` method for uninstalling methods, so a
645
+ * Remote method named `remove` conflicts with it.
646
+ * @param request - Server id to remove.
647
+ * @returns success, or not-found when the id is unknown.
648
+ */
649
+ async delete(request) {
650
+ const table = this.requireTable();
651
+ const row = table.get(request.id);
652
+ if (row === void 0) return {
653
+ ok: false,
654
+ error: this.notFound(request.id)
655
+ };
656
+ await this.serialize(request.id, async () => {
657
+ await this.disposeMount(request.id);
658
+ await table.delete(request.id);
659
+ });
660
+ for (const entry of row.env) if (entry.secret) await this.ctx.credentials.unset(secretRef(request.id, entry.name));
661
+ return { ok: true };
662
+ }
663
+ /**
664
+ * Probe one server configuration without persisting or mounting anything.
665
+ * Secret env values resolve from the submitted values, or from the stored
666
+ * credentials when the request carries an existing server id.
667
+ * @param request - The definition, env rows, and optional existing id.
668
+ * @returns the probe outcome and elapsed time; probing a broken server is a
669
+ * successful test call carrying a failure view.
670
+ */
671
+ async test(request) {
672
+ try {
673
+ validateServerInput(request.server, request.env);
674
+ } catch (error) {
675
+ if (error instanceof McpServerValidationError) return {
676
+ ok: true,
677
+ probe: {
678
+ ok: false,
679
+ message: error.message
680
+ },
681
+ elapsedMs: 0
682
+ };
683
+ throw error;
684
+ }
685
+ const env = {};
686
+ for (const entry of request.env) if (entry.secret) {
687
+ if (entry.value !== void 0 && entry.value.length > 0) env[entry.name] = entry.value;
688
+ else if (request.id !== void 0) {
689
+ const hit = await this.ctx.credentials.resolve(secretRef(request.id, entry.name));
690
+ if (hit !== void 0) env[entry.name] = hit.value;
691
+ }
692
+ } else if (entry.value !== void 0) env[entry.name] = entry.value;
693
+ const startedAt = Date.now();
694
+ const probe = await probeConnection(this.toClientConfig(request.server, env), { timeoutMs: this.probeTimeoutMs });
695
+ const elapsedMs = Date.now() - startedAt;
696
+ return {
697
+ ok: true,
698
+ probe: probe.ok ? {
699
+ ok: true,
700
+ tools: probe.tools
701
+ } : {
702
+ ok: false,
703
+ message: probe.message
704
+ },
705
+ elapsedMs
706
+ };
707
+ }
708
+ /** Server namespace of one `mcp__<server>__<tool>` public name. */
709
+ serverOf(name) {
710
+ const rest = name.slice(5);
711
+ const i = rest.indexOf("__");
712
+ return i < 0 ? rest : rest.slice(0, i);
713
+ }
714
+ /** Every registered MCP tool schema (global view). */
715
+ toolSnapshot() {
716
+ return this.ctx.tools.schemas().filter((tool) => tool.name.startsWith("mcp__"));
717
+ }
718
+ /** Record one tool as hot (most-recently-used), LRU-bounded. */
719
+ touchHot(name) {
720
+ this.hotTools.delete(name);
721
+ this.hotTools.set(name, Date.now());
722
+ while (this.hotTools.size > McpManagerService.HOT_LIMIT) {
723
+ const oldest = this.hotTools.keys().next().value;
724
+ if (oldest === void 0) break;
725
+ this.hotTools.delete(oldest);
726
+ }
727
+ }
728
+ /** Keyword score of one tool against query tokens: server name +2, tool name +3, description +1. */
729
+ score(tokens, name, description) {
730
+ const lowerName = name.toLowerCase();
731
+ const hay = `${name} ${description ?? ""}`.toLowerCase();
732
+ const server = this.serverOf(name);
733
+ let s = 0;
734
+ for (const token of tokens) {
735
+ if (server.includes(token)) s += 2;
736
+ else if (lowerName.includes(token)) s += 3;
737
+ else if (hay.includes(token)) s += 1;
738
+ }
739
+ return s;
740
+ }
741
+ /**
742
+ * Register the `mcp_tool_search` model tool and the injection-layer
743
+ * hooks: per-tool disable filtering plus search-mode hot-tool injection,
744
+ * both inside the `system-prompt/assemble` waterfall (no agent-loop
745
+ * change), and hot-set tracking on real tool calls.
746
+ */
747
+ installToolControl() {
748
+ this.ctx.tools.register({
749
+ name: "mcp_tool_search",
750
+ description: "按关键词检索当前启用的 MCP 工具,返回匹配工具的名称/描述/参数 schema,并将命中的工具加入热注入集(下一轮模型请求即可直接调用)。需要某个 MCP 工具但不知道确切名称时使用。",
751
+ parameters: {
752
+ type: "object",
753
+ properties: {
754
+ query: { type: "string", description: "检索关键词,如 gitlab merge request、feishu 文档、hive 表等" },
755
+ limit: { type: "number", description: "最多返回条数,默认 8,最大 20" }
756
+ },
757
+ required: ["query"],
758
+ additionalProperties: false
759
+ },
760
+ output: {
761
+ schema: {
762
+ type: "object",
763
+ properties: { content: { type: "array", items: {} } },
764
+ required: ["content"],
765
+ additionalProperties: false
766
+ },
767
+ render(_args, value) {
768
+ const content = value && Array.isArray(value.content) ? value.content : [];
769
+ return [{ type: "text", text: content.map((block) => block.text ?? "").join("\n") || "(no results)" }];
770
+ }
771
+ },
772
+ execute: async (args) => {
773
+ const q = String(args?.query ?? "").toLowerCase();
774
+ const limit = Math.min(Math.max(Number(args?.limit) || 8, 1), 20);
775
+ const tokens = q.split(/[^a-z0-9]+/).filter(Boolean);
776
+ const pool = this.toolSnapshot().filter((tool) => this.toolSwitches.get(tool.name) !== false);
777
+ const picked = tokens.length > 0
778
+ ? pool
779
+ .map((tool) => ({ tool, s: this.score(tokens, tool.name, tool.description) }))
780
+ .filter((entry) => entry.s > 0)
781
+ .sort((a, b) => b.s - a.s)
782
+ .slice(0, limit)
783
+ .map((entry) => entry.tool)
784
+ : pool.slice(0, limit);
785
+ for (const tool of picked) this.touchHot(tool.name);
786
+ const lines = picked.map((tool) => JSON.stringify({
787
+ name: tool.name,
788
+ description: String(tool.description ?? "").slice(0, 220),
789
+ parameters: tool.parameters
790
+ }));
791
+ return {
792
+ content: [{
793
+ type: "text",
794
+ text: lines.length > 0
795
+ ? "匹配 MCP 工具(已热启用,下一轮可直接调用):\n" + lines.join("\n")
796
+ : "未找到匹配的 MCP 工具,可换关键词重试。"
797
+ }]
798
+ };
799
+ }
800
+ });
801
+ this.ctx.on("system-prompt/assemble", (assembly, _context, next) => {
802
+ const kept = [];
803
+ const mcpByName = {};
804
+ for (const tool of assembly.tools) {
805
+ if (!tool.name.startsWith("mcp__")) {
806
+ kept.push(tool);
807
+ continue;
808
+ }
809
+ if (this.toolSwitches.get(tool.name) === false) continue;
810
+ mcpByName[tool.name] = tool;
811
+ if (this.toolMode !== "search") kept.push(tool);
812
+ }
813
+ if (this.toolMode === "search") {
814
+ for (const name of this.hotTools.keys()) {
815
+ const tool = mcpByName[name];
816
+ if (tool) kept.push(tool);
817
+ }
818
+ const servers = [...new Set(Object.keys(mcpByName).map((name) => this.serverOf(name)))].sort();
819
+ assembly.sections = (assembly.sections || []).filter((section) => section.name !== "mcp-tool-control");
820
+ assembly.sections.push({
821
+ name: "mcp-tool-control",
822
+ text: "MCP 工具按需可用:需要某个 MCP 工具时先调用 mcp_tool_search(query) 检索,命中后该工具会自动注入当前对话;当前可用 MCP 服务器:" + (servers.join(", ") || "(无)") + "。"
823
+ });
824
+ }
825
+ assembly.tools = kept;
826
+ return next();
827
+ });
828
+ this.ctx.on("tools/result", (exec) => {
829
+ if (exec && typeof exec.name === "string" && exec.name.startsWith("mcp__")) this.touchHot(exec.name);
830
+ });
831
+ }
832
+ /**
833
+ * Read every registered MCP tool with its enable switch and the current
834
+ * injection mode / hot-set size, for the Settings page.
835
+ * @returns the tool-control state.
836
+ */
837
+ async toolsList() {
838
+ const tools = this.toolSnapshot().map((tool) => ({
839
+ name: tool.name,
840
+ server: this.serverOf(tool.name),
841
+ description: String(tool.description ?? "").slice(0, 140),
842
+ enabled: this.toolSwitches.get(tool.name) !== false
843
+ }));
844
+ return {
845
+ ok: true,
846
+ tools,
847
+ mode: this.toolMode,
848
+ hotSize: this.hotTools.size
849
+ };
850
+ }
851
+ /**
852
+ * Set the enable switch of one MCP tool.
853
+ * @param request - Tool name plus the desired enabled state.
854
+ * @returns success or a failure.
855
+ */
856
+ async toolsSet(request) {
857
+ if (typeof request?.name !== "string" || !request.name.startsWith("mcp__")) {
858
+ return {
859
+ ok: false,
860
+ error: {
861
+ code: "MCP_TOOL_NOT_FOUND",
862
+ message: `invalid MCP tool name "${String(request?.name ?? "")}"`
863
+ }
864
+ };
865
+ }
866
+ this.toolSwitches.set(request.name, request.enabled === true);
867
+ return { ok: true };
868
+ }
869
+ /**
870
+ * Switch the injection mode between `full` (every enabled tool per
871
+ * request) and `search` (resident + hot tools only). Switching clears
872
+ * the hot set.
873
+ * @param request - The mode to apply.
874
+ * @returns the applied mode, or a failure.
875
+ */
876
+ async toolsMode(request) {
877
+ const mode = request?.mode;
878
+ if (mode !== "full" && mode !== "search") {
879
+ return {
880
+ ok: false,
881
+ error: {
882
+ code: "MCP_TOOL_INVALID_MODE",
883
+ message: "mode must be full|search"
884
+ }
885
+ };
886
+ }
887
+ this.toolMode = mode;
888
+ this.hotTools.clear();
889
+ return { ok: true, mode };
890
+ }
891
+ };
892
+ })();
893
+ //#endregion
894
+ export { Config, McpManagerService, McpManagerService as default };