@vellumai/assistant 0.11.3-dev.202608111333.7065e2a → 0.11.3-dev.202608111429.ed4a99b
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/package.json +1 -1
- package/src/__tests__/mcp-list-plugin-servers.test.ts +2 -2
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/config/schemas/mcp.ts +23 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/providers-setup.ts +6 -3
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +12 -4
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/plugins/__tests__/mcp-servers.test.ts +5 -4
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/mcp-servers.ts +26 -8
- package/src/plugins/mtime-cache.ts +12 -0
- package/src/runtime/routes/mcp-auth-routes.ts +16 -9
package/package.json
CHANGED
|
@@ -204,12 +204,12 @@ describe("internal_mcp_list, plugin-declared servers", () => {
|
|
|
204
204
|
expect(workspace.hasStaticAuth).toBe(true);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
-
test("plugin servers default to
|
|
207
|
+
test("plugin servers default to low risk", async () => {
|
|
208
208
|
writePlugin("unabyss", unabyssManifest());
|
|
209
209
|
|
|
210
210
|
const servers = await listServers();
|
|
211
211
|
expect(servers.find((s) => s.id === "unabyss")!.defaultRiskLevel).toEqual(
|
|
212
|
-
"
|
|
212
|
+
"low",
|
|
213
213
|
);
|
|
214
214
|
});
|
|
215
215
|
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Tests for the `assistant memory v3` CLI subgroup (section-lane model).
|
|
3
3
|
*
|
|
4
4
|
* Validates:
|
|
5
|
-
* - Subcommand registration (rebuild-index, backfill-sections)
|
|
6
|
-
* `memory v3`.
|
|
7
|
-
* -
|
|
5
|
+
* - Subcommand registration (rebuild-index, backfill-sections, gate-stats)
|
|
6
|
+
* under `memory v3`.
|
|
7
|
+
* - `rebuild-index` and `backfill-sections` route through IPC to the daemon.
|
|
8
8
|
* - `backfill-sections` passes a long IPC timeout (the one-time full-corpus
|
|
9
|
-
* embed easily outlasts the default 60s)
|
|
10
|
-
* -
|
|
9
|
+
* embed easily outlasts the default 60s).
|
|
10
|
+
* - `gate-stats` calls handleMemoryV3GateStats directly — no daemon required.
|
|
11
|
+
* - Error paths return a non-zero exit code without throwing.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -39,6 +40,47 @@ let mockIpcResult: {
|
|
|
39
40
|
/** Captured log output for assertion. */
|
|
40
41
|
let logOutput: string[] = [];
|
|
41
42
|
|
|
43
|
+
/** Shared fake gate-stats payload used across gate-stats tests. */
|
|
44
|
+
const fakeGateStats = {
|
|
45
|
+
lookbackDays: 30,
|
|
46
|
+
totalRuns: 10,
|
|
47
|
+
buckets: [
|
|
48
|
+
{
|
|
49
|
+
pageCountRange: "0–9",
|
|
50
|
+
total: 2,
|
|
51
|
+
scored: 1,
|
|
52
|
+
passed: 2,
|
|
53
|
+
scoredPassRate: 1,
|
|
54
|
+
reasons: { dense_pass: 1, dense_disabled: 1 },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pageCountRange: "10–49",
|
|
58
|
+
total: 5,
|
|
59
|
+
scored: 4,
|
|
60
|
+
passed: 3,
|
|
61
|
+
scoredPassRate: 0.75,
|
|
62
|
+
reasons: { dense_pass: 3, fail_no_signal: 1 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pageCountRange: "50–199",
|
|
66
|
+
total: 2,
|
|
67
|
+
scored: 2,
|
|
68
|
+
passed: 1,
|
|
69
|
+
scoredPassRate: 0.5,
|
|
70
|
+
reasons: { dense_pass: 1, fail_no_signal: 1 },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
pageCountRange: "200+",
|
|
74
|
+
total: 1,
|
|
75
|
+
scored: 1,
|
|
76
|
+
passed: 0,
|
|
77
|
+
scoredPassRate: 0,
|
|
78
|
+
reasons: { fail_no_signal: 1 },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
unknownPageCount: { total: 0, passed: 0, reasons: {} },
|
|
82
|
+
};
|
|
83
|
+
|
|
42
84
|
// ---------------------------------------------------------------------------
|
|
43
85
|
// Mocks
|
|
44
86
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +110,33 @@ mock.module("../../../../util/logger.js", () => ({
|
|
|
68
110
|
getCliLogger: () => fakeLogger,
|
|
69
111
|
}));
|
|
70
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// gate-stats direct-invocation mocks
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Args captured from the last handleMemoryV3GateStats call. */
|
|
118
|
+
let lastGateStatsArgs: { lookbackDays: number; db: unknown } | null = null;
|
|
119
|
+
|
|
120
|
+
/** What handleMemoryV3GateStats returns, or an error to throw. */
|
|
121
|
+
let gateStatsImpl: (() => unknown) | null = null;
|
|
122
|
+
|
|
123
|
+
mock.module(
|
|
124
|
+
"../../../../plugins/defaults/memory/src/memory-v3-routes.js",
|
|
125
|
+
() => ({
|
|
126
|
+
handleMemoryV3GateStats: (lookbackDays: number, db: unknown) => {
|
|
127
|
+
lastGateStatsArgs = { lookbackDays, db };
|
|
128
|
+
if (gateStatsImpl) {
|
|
129
|
+
return gateStatsImpl();
|
|
130
|
+
}
|
|
131
|
+
return fakeGateStats;
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
mock.module("../../../../persistence/db-connection.js", () => ({
|
|
137
|
+
getTelemetrySqlite: () => ({}),
|
|
138
|
+
}));
|
|
139
|
+
|
|
71
140
|
// ---------------------------------------------------------------------------
|
|
72
141
|
// Import modules under test (after mocks)
|
|
73
142
|
// ---------------------------------------------------------------------------
|
|
@@ -135,6 +204,8 @@ beforeEach(() => {
|
|
|
135
204
|
ok: true,
|
|
136
205
|
result: { articles: 3, sections: 12, failures: 0 },
|
|
137
206
|
};
|
|
207
|
+
lastGateStatsArgs = null;
|
|
208
|
+
gateStatsImpl = null;
|
|
138
209
|
logOutput = [];
|
|
139
210
|
process.exitCode = 0;
|
|
140
211
|
});
|
|
@@ -155,6 +226,7 @@ describe("subcommand registration", () => {
|
|
|
155
226
|
"backfill-sections",
|
|
156
227
|
"eval",
|
|
157
228
|
"eval-tally",
|
|
229
|
+
"gate-stats",
|
|
158
230
|
"rebuild-index",
|
|
159
231
|
]);
|
|
160
232
|
});
|
|
@@ -236,3 +308,54 @@ describe("memory v3 backfill-sections", () => {
|
|
|
236
308
|
expect(exitCode).toBe(1);
|
|
237
309
|
});
|
|
238
310
|
});
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// gate-stats (direct invocation — no daemon required)
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
describe("memory v3 gate-stats", () => {
|
|
317
|
+
test("calls handleMemoryV3GateStats directly with default lookback (no IPC)", async () => {
|
|
318
|
+
const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
|
|
319
|
+
|
|
320
|
+
expect(exitCode).toBe(0);
|
|
321
|
+
// Must NOT have gone through IPC.
|
|
322
|
+
expect(lastIpcCall).toBeNull();
|
|
323
|
+
// Must have called the handler directly with the default lookback.
|
|
324
|
+
expect(lastGateStatsArgs!.lookbackDays).toBe(30);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("forwards --lookback-days directly to handleMemoryV3GateStats", async () => {
|
|
328
|
+
await runCommand(["memory", "v3", "gate-stats", "--lookback-days", "7"]);
|
|
329
|
+
|
|
330
|
+
expect(lastIpcCall).toBeNull();
|
|
331
|
+
expect(lastGateStatsArgs!.lookbackDays).toBe(7);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("prints formatted table on success (no --json)", async () => {
|
|
335
|
+
await runCommand(["memory", "v3", "gate-stats"]);
|
|
336
|
+
|
|
337
|
+
expect(logOutput.some((l) => l.includes("Gate-stats"))).toBe(true);
|
|
338
|
+
expect(logOutput.some((l) => l.includes("10–49"))).toBe(true);
|
|
339
|
+
expect(logOutput.some((l) => l.includes("75.0%"))).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("emits raw JSON with --json", async () => {
|
|
343
|
+
await runCommand(["memory", "v3", "gate-stats", "--json"]);
|
|
344
|
+
|
|
345
|
+
const raw = logOutput.join("");
|
|
346
|
+
const parsed = JSON.parse(raw) as typeof fakeGateStats;
|
|
347
|
+
expect(parsed.totalRuns).toBe(10);
|
|
348
|
+
expect(parsed.buckets).toHaveLength(4);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("exits with code 1 when handleMemoryV3GateStats throws", async () => {
|
|
352
|
+
gateStatsImpl = () => {
|
|
353
|
+
throw new Error("telemetry DB locked");
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
|
|
357
|
+
|
|
358
|
+
expect(exitCode).toBe(1);
|
|
359
|
+
expect(logOutput.some((l) => l.includes("telemetry DB locked"))).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -606,7 +606,9 @@ that live state safely.
|
|
|
606
606
|
|
|
607
607
|
Examples:
|
|
608
608
|
$ assistant memory v3 rebuild-index
|
|
609
|
-
$ assistant memory v3 backfill-sections
|
|
609
|
+
$ assistant memory v3 backfill-sections
|
|
610
|
+
$ assistant memory v3 gate-stats
|
|
611
|
+
$ assistant memory v3 gate-stats --lookback-days 7 --json`,
|
|
610
612
|
subcommands: [
|
|
611
613
|
{
|
|
612
614
|
name: "rebuild-index",
|
|
@@ -716,6 +718,46 @@ Examples:
|
|
|
716
718
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval
|
|
717
719
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --turns-file .mv3/eval/key.json
|
|
718
720
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --exclude-conversation <migration-conv-id>`,
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "gate-stats",
|
|
724
|
+
description:
|
|
725
|
+
"Show injection gate pass rates bucketed by corpus size (read-only)",
|
|
726
|
+
options: [
|
|
727
|
+
{
|
|
728
|
+
flags: "--lookback-days <n>",
|
|
729
|
+
description: "Days of telemetry to aggregate (1–90, default 30)",
|
|
730
|
+
defaultValue: "30",
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
flags: "--json",
|
|
734
|
+
description: "Emit raw JSON instead of a formatted table",
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
helpText: `
|
|
738
|
+
Reads the memory v3 injection gate telemetry outbox and prints pass rates
|
|
739
|
+
and reason distributions grouped by concept page count bucket
|
|
740
|
+
(0–9 / 10–49 / 50–199 / 200+). Scored runs (dense lane was available and
|
|
741
|
+
actually weighed scores) are reported separately from pass-open shortcuts
|
|
742
|
+
(dense disabled / unavailable / gate threw) so scoredPassRate reflects
|
|
743
|
+
only contested gate decisions — the signal relevant for threshold calibration.
|
|
744
|
+
|
|
745
|
+
Coverage is limited to runs still pending platform flush. In a healthy system
|
|
746
|
+
the outbox holds only the last few minutes to hours of events, so
|
|
747
|
+
--lookback-days is an upper bound, not a guarantee; long-window aggregation
|
|
748
|
+
lives on the platform side on top of flushed watchdog events.
|
|
749
|
+
|
|
750
|
+
Reads the telemetry database directly — the assistant does not need to be
|
|
751
|
+
running.
|
|
752
|
+
|
|
753
|
+
Intended use: verify that gate firing rates shift as expected after a
|
|
754
|
+
config or threshold change, and check whether the bm25-auto-calibration
|
|
755
|
+
flag is worth enabling for large corpora.
|
|
756
|
+
|
|
757
|
+
Examples:
|
|
758
|
+
$ assistant memory v3 gate-stats
|
|
759
|
+
$ assistant memory v3 gate-stats --lookback-days 7
|
|
760
|
+
$ assistant memory v3 gate-stats --json | jq '.buckets[] | select(.pageCountRange == "200+")'`,
|
|
719
761
|
},
|
|
720
762
|
{
|
|
721
763
|
name: "eval-tally",
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
MemoryEvalTallyResult,
|
|
26
26
|
} from "../../../plugins/defaults/memory/src/memory-eval-routes.js";
|
|
27
27
|
import type {
|
|
28
|
+
GateStatsResponse,
|
|
28
29
|
MemoryV3BackfillSectionsResult,
|
|
29
30
|
MemoryV3RebuildIndexResult,
|
|
30
31
|
} from "../../../plugins/defaults/memory/src/memory-v3-routes.js";
|
|
@@ -52,6 +53,36 @@ function collectRepeatable(value: string, acc: string[]): string[] {
|
|
|
52
53
|
return [...acc, value];
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Render gate-stats as a human-readable table. */
|
|
57
|
+
function formatGateStats(r: GateStatsResponse): string {
|
|
58
|
+
const lines: string[] = [
|
|
59
|
+
`Gate-stats (last ${r.lookbackDays} day${r.lookbackDays === 1 ? "" : "s"}, ${r.totalRuns} run${r.totalRuns === 1 ? "" : "s"})`,
|
|
60
|
+
"",
|
|
61
|
+
`Bucket Total Scored Passed ScoredPassRate Top reasons`,
|
|
62
|
+
];
|
|
63
|
+
for (const b of r.buckets) {
|
|
64
|
+
const rate =
|
|
65
|
+
b.scoredPassRate !== null
|
|
66
|
+
? `${(b.scoredPassRate * 100).toFixed(1)}%`
|
|
67
|
+
: "n/a";
|
|
68
|
+
const topReasons = Object.entries(b.reasons)
|
|
69
|
+
.sort(([, a], [, bv]) => (bv as number) - (a as number))
|
|
70
|
+
.slice(0, 3)
|
|
71
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
lines.push(
|
|
74
|
+
`${b.pageCountRange.padEnd(10)} ${String(b.total).padStart(5)} ${String(b.scored).padStart(6)} ${String(b.passed).padStart(6)} ${rate.padStart(14)} ${topReasons}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (r.unknownPageCount.total > 0) {
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(
|
|
80
|
+
`Unknown page count: ${r.unknownPageCount.total} total, ${r.unknownPageCount.passed} passed`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* Read the `turn` ids from a prior run's `key.json` or `packets.json` (both are
|
|
57
88
|
* arrays of objects carrying a `turn` field). Used to pin `--turns-file` so a
|
|
@@ -271,4 +302,37 @@ export function registerMemoryV3Command(memory: Command): void {
|
|
|
271
302
|
log.info(formatTally(payload));
|
|
272
303
|
},
|
|
273
304
|
);
|
|
305
|
+
|
|
306
|
+
// ── gate-stats ────────────────────────────────────────────────────────
|
|
307
|
+
// Reads the telemetry DB directly — no daemon required.
|
|
308
|
+
|
|
309
|
+
subcommand(v3, "gate-stats").action(
|
|
310
|
+
async (opts: { lookbackDays: string; json?: boolean }) => {
|
|
311
|
+
const lookbackDays = Number(opts.lookbackDays);
|
|
312
|
+
const [{ handleMemoryV3GateStats }, { getTelemetrySqlite }] =
|
|
313
|
+
await Promise.all([
|
|
314
|
+
import("../../../plugins/defaults/memory/src/memory-v3-routes.js"),
|
|
315
|
+
import("../../../persistence/db-connection.js"),
|
|
316
|
+
]);
|
|
317
|
+
let payload;
|
|
318
|
+
try {
|
|
319
|
+
payload = handleMemoryV3GateStats(
|
|
320
|
+
Number.isFinite(lookbackDays) ? lookbackDays : 30,
|
|
321
|
+
getTelemetrySqlite(),
|
|
322
|
+
);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.error(
|
|
325
|
+
"Failed to read gate stats: %s",
|
|
326
|
+
err instanceof Error ? err.message : String(err),
|
|
327
|
+
);
|
|
328
|
+
process.exitCode = 1;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (opts.json === true) {
|
|
332
|
+
log.info(JSON.stringify(payload, null, 2));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
log.info(formatGateStats(payload));
|
|
336
|
+
},
|
|
337
|
+
);
|
|
274
338
|
}
|
|
@@ -422,6 +422,19 @@
|
|
|
422
422
|
"license": "MIT",
|
|
423
423
|
"icon": "✈️"
|
|
424
424
|
},
|
|
425
|
+
{
|
|
426
|
+
"name": "unabyss",
|
|
427
|
+
"source": {
|
|
428
|
+
"source": "github",
|
|
429
|
+
"repo": "Unabyss/unabyss-vellum",
|
|
430
|
+
"ref": "9f0bb0753dad09dace8578e242961bc2da912af3"
|
|
431
|
+
},
|
|
432
|
+
"description": "Personal and company context from Unabyss, available to your agent through the Unabyss MCP server.",
|
|
433
|
+
"category": "memory",
|
|
434
|
+
"homepage": "https://unabyss.com",
|
|
435
|
+
"license": "MIT",
|
|
436
|
+
"icon": "🌊"
|
|
437
|
+
},
|
|
425
438
|
{
|
|
426
439
|
"name": "vellum-client-qa",
|
|
427
440
|
"source": {
|
|
@@ -109,3 +109,26 @@ export const McpConfigSchema = z
|
|
|
109
109
|
export type McpTransport = z.infer<typeof McpTransportSchema>;
|
|
110
110
|
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
|
111
111
|
export type McpConfig = z.infer<typeof McpConfigSchema>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Who declared a server: the workspace `config.json`, which the user owns,
|
|
115
|
+
* or a plugin's `mcp.json`, which its author owns.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately not a schema field. It is resolved from where the entry was
|
|
118
|
+
* read, never parsed from a file, so nothing on disk can claim to be
|
|
119
|
+
* workspace-owned. What it gates is credential access: only a workspace
|
|
120
|
+
* server resolves `mcp:<serverId>:*` from the credential store, because a
|
|
121
|
+
* plugin controls both its server key and its URL and would otherwise
|
|
122
|
+
* receive a workspace credential at an endpoint it chose.
|
|
123
|
+
*/
|
|
124
|
+
export type McpServerSource = "workspace" | "plugin";
|
|
125
|
+
|
|
126
|
+
/** A server config with its origin resolved. */
|
|
127
|
+
export interface ResolvedMcpServerConfig extends McpServerConfig {
|
|
128
|
+
readonly source: McpServerSource;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The MCP config the daemon runs: both origins, every server attributed. */
|
|
132
|
+
export interface ResolvedMcpConfig extends Omit<McpConfig, "servers"> {
|
|
133
|
+
readonly servers: Record<string, ResolvedMcpServerConfig>;
|
|
134
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin source reconcile drives MCP reload.
|
|
3
|
+
*
|
|
4
|
+
* Without this, installing a plugin leaves its `mcp.json` servers
|
|
5
|
+
* unconnected and its tools unregistered until an explicit MCP reload, a
|
|
6
|
+
* config edit, or a restart — and, worse in the other direction, an
|
|
7
|
+
* uninstalled or disabled plugin's connected client and registered tools
|
|
8
|
+
* stay callable. The reconcile is the seam every install / uninstall /
|
|
9
|
+
* upgrade / enable / disable already funnels through.
|
|
10
|
+
*
|
|
11
|
+
* The guard is equally load-bearing: a reconcile also fires for plugin
|
|
12
|
+
* edits that touch no `mcp.json`, and reloading on those would tear down
|
|
13
|
+
* every healthy workspace connection to rebuild it for nothing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
let pluginServersChanged = true;
|
|
19
|
+
|
|
20
|
+
const managerStop = jest.fn(async () => {});
|
|
21
|
+
const managerStart = jest.fn(async () => []);
|
|
22
|
+
|
|
23
|
+
mock.module("../../mcp/effective-config.js", () => ({
|
|
24
|
+
buildEffectiveMcpConfig: () => ({
|
|
25
|
+
servers: {
|
|
26
|
+
unabyss: {
|
|
27
|
+
transport: { type: "streamable-http", url: "https://mcp.example/x" },
|
|
28
|
+
enabled: true,
|
|
29
|
+
defaultRiskLevel: "low",
|
|
30
|
+
maxTools: 20,
|
|
31
|
+
source: "plugin",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
globalMaxTools: 50,
|
|
35
|
+
}),
|
|
36
|
+
pluginMcpServersChangedSinceLastBuild: () => pluginServersChanged,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module("../../mcp/manager.js", () => ({
|
|
40
|
+
getMcpServerManager: () => ({ stop: managerStop, start: managerStart }),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module("../../mcp/mcp-header-store.js", () => ({
|
|
44
|
+
migrateLegacyMcpHeaders: async () => {},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
mock.module("../../config/loader.js", () => ({
|
|
48
|
+
getConfig: () => ({ mcp: { servers: {}, globalMaxTools: 50 } }),
|
|
49
|
+
invalidateConfigCache: () => {},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
mock.module("../../tools/registry.js", () => ({
|
|
53
|
+
registerMcpTools: () => [],
|
|
54
|
+
unregisterAllMcpTools: () => {},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const { reconcilePluginMcpServers } = await import("../mcp-reload-service.js");
|
|
58
|
+
|
|
59
|
+
describe("reconcilePluginMcpServers", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
managerStop.mockClear();
|
|
62
|
+
managerStart.mockClear();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reloads when the plugin-declared set changed", async () => {
|
|
66
|
+
pluginServersChanged = true;
|
|
67
|
+
|
|
68
|
+
await reconcilePluginMcpServers();
|
|
69
|
+
|
|
70
|
+
expect(managerStop).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(managerStart).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("does nothing when no plugin changed the set", async () => {
|
|
75
|
+
pluginServersChanged = false;
|
|
76
|
+
|
|
77
|
+
await reconcilePluginMcpServers();
|
|
78
|
+
|
|
79
|
+
expect(managerStop).not.toHaveBeenCalled();
|
|
80
|
+
expect(managerStart).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getConfig, invalidateConfigCache } from "../config/loader.js";
|
|
9
|
+
import {
|
|
10
|
+
buildEffectiveMcpConfig,
|
|
11
|
+
pluginMcpServersChangedSinceLastBuild,
|
|
12
|
+
} from "../mcp/effective-config.js";
|
|
9
13
|
import { getMcpServerManager } from "../mcp/manager.js";
|
|
10
14
|
import { migrateLegacyMcpHeaders } from "../mcp/mcp-header-store.js";
|
|
11
15
|
import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
|
|
@@ -52,6 +56,29 @@ export function reloadMcpServers(): Promise<McpReloadResult> {
|
|
|
52
56
|
return reloadInProgress;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Reload MCP servers if — and only if — the plugin-declared set moved.
|
|
61
|
+
*
|
|
62
|
+
* Called by the plugin source reconcile, which every install / uninstall /
|
|
63
|
+
* upgrade / enable / disable already funnels through, so a plugin's MCP
|
|
64
|
+
* tools come up and go down with the plugin instead of lingering until an
|
|
65
|
+
* explicit reload, a config edit, or a restart.
|
|
66
|
+
*
|
|
67
|
+
* The guard matters: a reconcile also fires for plugin edits that touch no
|
|
68
|
+
* `mcp.json` at all, and an unconditional reload would tear down every
|
|
69
|
+
* healthy workspace connection to re-establish it for nothing.
|
|
70
|
+
*
|
|
71
|
+
* Never throws — the reconcile is best-effort and must not fail an install
|
|
72
|
+
* whose files already landed on disk.
|
|
73
|
+
*/
|
|
74
|
+
export async function reconcilePluginMcpServers(): Promise<void> {
|
|
75
|
+
if (!pluginMcpServersChangedSinceLastBuild()) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
log.info("Plugin-declared MCP servers changed; reloading");
|
|
79
|
+
await reloadMcpServers();
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
async function doReload(): Promise<McpReloadResult> {
|
|
56
83
|
try {
|
|
57
84
|
const manager = getMcpServerManager();
|
|
@@ -74,17 +101,20 @@ async function doReload(): Promise<McpReloadResult> {
|
|
|
74
101
|
// 2. Stop existing MCP servers + unregister their tools
|
|
75
102
|
await manager.stop();
|
|
76
103
|
unregisterAllMcpTools();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
|
|
105
|
+
// Plugins are re-read here too: installing or removing one changes the
|
|
106
|
+
// server set exactly like editing config.json does, and both arrive
|
|
107
|
+
// through this same reload.
|
|
108
|
+
const mcpConfig = buildEffectiveMcpConfig(config.mcp);
|
|
109
|
+
const serverIds = Object.keys(mcpConfig.servers);
|
|
80
110
|
|
|
81
111
|
// 3. Restart MCP servers
|
|
82
112
|
let serverCount = 0;
|
|
83
113
|
let toolCount = 0;
|
|
84
114
|
const servers: McpReloadServerResult[] = [];
|
|
85
115
|
|
|
86
|
-
if (
|
|
87
|
-
const serverToolInfos = await manager.start(
|
|
116
|
+
if (serverIds.length > 0) {
|
|
117
|
+
const serverToolInfos = await manager.start(mcpConfig);
|
|
88
118
|
for (const { serverId, serverConfig, tools } of serverToolInfos) {
|
|
89
119
|
const mcpTools = createMcpToolsFromServer(
|
|
90
120
|
tools,
|
|
@@ -105,7 +135,7 @@ async function doReload(): Promise<McpReloadResult> {
|
|
|
105
135
|
// Include servers that were configured but failed to connect or are disabled
|
|
106
136
|
for (const id of serverIds) {
|
|
107
137
|
if (!servers.some((s) => s.id === id)) {
|
|
108
|
-
const serverConfig =
|
|
138
|
+
const serverConfig = mcpConfig.servers[id];
|
|
109
139
|
const isDisabled = serverConfig?.enabled === false;
|
|
110
140
|
servers.push({
|
|
111
141
|
id,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { maybeDefaultSpeechToManaged } from "../config/managed-speech-defaults.js";
|
|
2
2
|
import { rehydratePlatformCredentials } from "../config/platform-rehydration.js";
|
|
3
3
|
import type { AssistantConfig } from "../config/types.js";
|
|
4
|
+
import { buildEffectiveMcpConfig } from "../mcp/effective-config.js";
|
|
4
5
|
import { getMcpServerManager } from "../mcp/manager.js";
|
|
5
6
|
import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
|
|
6
7
|
import { outlookMessagingProvider } from "../messaging/providers/outlook/adapter.js";
|
|
@@ -61,11 +62,13 @@ export async function initializeProvidersAndTools(
|
|
|
61
62
|
);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
// Start MCP servers and
|
|
65
|
-
|
|
65
|
+
// Start MCP servers — workspace-configured and plugin-declared alike —
|
|
66
|
+
// and register their tools.
|
|
67
|
+
const mcpConfig = buildEffectiveMcpConfig(config.mcp);
|
|
68
|
+
if (Object.keys(mcpConfig.servers).length > 0) {
|
|
66
69
|
const manager = getMcpServerManager();
|
|
67
70
|
try {
|
|
68
|
-
const serverToolInfos = await manager.start(
|
|
71
|
+
const serverToolInfos = await manager.start(mcpConfig);
|
|
69
72
|
for (const { serverId, serverConfig, tools } of serverToolInfos) {
|
|
70
73
|
const mcpTools = createMcpToolsFromServer(
|
|
71
74
|
tools,
|