@vellumai/assistant 0.11.3-dev.202608111333.7065e2a → 0.11.3-dev.202608111523.8a0fdc8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608111333.7065e2a",
3
+ "version": "0.11.3-dev.202608111523.8a0fdc8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 high risk", async () => {
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
- "high",
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) under
6
- * `memory v3`.
7
- * - Each subcommand maps to the right `cliIpcCall` method.
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), while `rebuild-index` does not.
10
- * - IPC error paths return a non-zero exit code without throwing.
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
- const serverIds = config.mcp?.servers
78
- ? Object.keys(config.mcp.servers)
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 (config.mcp?.servers && Object.keys(config.mcp.servers).length > 0) {
87
- const serverToolInfos = await manager.start(config.mcp);
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 = config.mcp!.servers![id];
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 register their tools
65
- if (config.mcp?.servers && Object.keys(config.mcp.servers).length > 0) {
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(config.mcp);
71
+ const serverToolInfos = await manager.start(mcpConfig);
69
72
  for (const { serverId, serverConfig, tools } of serverToolInfos) {
70
73
  const mcpTools = createMcpToolsFromServer(
71
74
  tools,