@vellumai/assistant 0.12.2-dev.202609171225.565e464 → 0.12.2-dev.202609171516.2acdfd4

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/config-watcher.test.ts +24 -0
  3. package/src/__tests__/credential-execution-client.test.ts +5 -5
  4. package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
  5. package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
  6. package/src/config/schemas/__tests__/tools.test.ts +31 -0
  7. package/src/config/schemas/mcp.ts +2 -1
  8. package/src/config/schemas/tools.ts +10 -0
  9. package/src/config/skills.ts +2 -6
  10. package/src/credential-execution/executable-discovery.ts +1 -11
  11. package/src/daemon/config-watcher.ts +10 -0
  12. package/src/daemon/conversation-skill-tools.ts +1 -0
  13. package/src/mcp/__tests__/manager-tool-caps.test.ts +33 -0
  14. package/src/mcp/__tests__/tool-caps.test.ts +28 -1
  15. package/src/mcp/manager.ts +3 -0
  16. package/src/mcp/tool-caps.ts +12 -4
  17. package/src/monitoring/__tests__/control.test.ts +17 -5
  18. package/src/monitoring/control.ts +15 -5
  19. package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
  20. package/src/plugin-api/index.ts +5 -3
  21. package/src/plugin-api/plugin-name-env.ts +125 -0
  22. package/src/plugin-api/resolve-credential.ts +79 -20
  23. package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
  24. package/src/plugins/active-plugin-env.ts +51 -0
  25. package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
  26. package/src/plugins/defaults/memory/worker-control.ts +15 -5
  27. package/src/routes/control.ts +7 -5
  28. package/src/schedule/__tests__/worker-control.test.ts +36 -5
  29. package/src/schedule/worker-control.ts +15 -5
  30. package/src/skills/clawhub.ts +3 -8
  31. package/src/skills/managed-store.ts +1 -5
  32. package/src/tools/host-terminal/host-shell.ts +4 -1
  33. package/src/tools/skills/sandbox-runner.ts +11 -2
  34. package/src/tools/skills/skill-script-runner.ts +7 -0
  35. package/src/tools/skills/skill-tool-factory.ts +4 -1
  36. package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
  37. package/src/tools/terminal/safe-env.ts +4 -0
  38. package/src/tools/terminal/shell.ts +2 -0
  39. package/src/util/__tests__/worker-process-command.test.ts +5 -4
  40. package/src/util/worker-process.ts +77 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609171225.565e464",
3
+ "version": "0.12.2-dev.202609171516.2acdfd4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -315,6 +315,30 @@ describe("ConfigWatcher workspace file handlers", () => {
315
315
  expect(evictCallCount).toBe(1);
316
316
  });
317
317
 
318
+ test("config.json change reloads MCP when tools.mcpGlobalMaxTools changes", async () => {
319
+ watcher.initFingerprint({
320
+ tools: { exclude: [], mcpGlobalMaxTools: 50 },
321
+ } as never);
322
+ watcher.refreshConfigFromSources = async () => true;
323
+ watcher.start();
324
+ simulateFileChange(WORKSPACE_DIR, "config.json");
325
+ await new Promise((r) => setTimeout(r, WAIT_MS));
326
+ expect(mcpReloadCallCount).toBe(1);
327
+ expect(evictCallCount).toBe(1);
328
+ });
329
+
330
+ test("config.json change does not reload MCP when the global max is unchanged", async () => {
331
+ watcher.initFingerprint({
332
+ tools: { exclude: [] },
333
+ } as never);
334
+ watcher.refreshConfigFromSources = async () => true;
335
+ watcher.start();
336
+ simulateFileChange(WORKSPACE_DIR, "config.json");
337
+ await new Promise((r) => setTimeout(r, WAIT_MS));
338
+ expect(mcpReloadCallCount).toBe(0);
339
+ expect(evictCallCount).toBe(1);
340
+ });
341
+
318
342
  test("config.json change is suppressed when suppressConfigReload is true", async () => {
319
343
  let refreshCalled = false;
320
344
  watcher.refreshConfigFromSources = async () => {
@@ -31,8 +31,8 @@ import {
31
31
  createCesClient,
32
32
  } from "../credential-execution/client.js";
33
33
  import {
34
+ discoverCes,
34
35
  discoverCesWithRetry,
35
- discoverManagedCes,
36
36
  } from "../credential-execution/executable-discovery.js";
37
37
 
38
38
  // ---------------------------------------------------------------------------
@@ -100,7 +100,7 @@ describe("managed CES discovery", () => {
100
100
  const bootstrapDir = mkdtempSync(join(tmpdir(), "ces-missing-"));
101
101
  const restore = withBootstrapDir(bootstrapDir);
102
102
  try {
103
- const result = discoverManagedCes();
103
+ const result = discoverCes();
104
104
  expect(result.mode).toBe("unavailable");
105
105
  expect((result as { reason: string }).reason).toContain(
106
106
  "CES bootstrap socket not found",
@@ -115,7 +115,7 @@ describe("managed CES discovery", () => {
115
115
  const bootstrapDir = mkdtempSync(join(tmpdir(), "ces-missing-"));
116
116
  const restore = withBootstrapDir(bootstrapDir);
117
117
  try {
118
- const result = discoverManagedCes();
118
+ const result = discoverCes();
119
119
  expect(["managed", "unavailable"]).toContain(result.mode);
120
120
  } finally {
121
121
  restore();
@@ -132,7 +132,7 @@ describe("CES bootstrap socket discovery", () => {
132
132
  const socketPath = resolveIpcEndpoint("ces", {
133
133
  workspaceDir: bootstrapDir,
134
134
  }).path;
135
- const result = discoverManagedCes();
135
+ const result = discoverCes();
136
136
  expect(result.mode).toBe("unavailable");
137
137
  expect((result as { reason: string }).reason).toContain(socketPath);
138
138
  } finally {
@@ -145,7 +145,7 @@ describe("CES bootstrap socket discovery", () => {
145
145
  const bootstrapDir = mkdtempSync(join(tmpdir(), "ces-bootstrap-"));
146
146
  const restore = withBootstrapDir(bootstrapDir);
147
147
  try {
148
- const result = discoverManagedCes();
148
+ const result = discoverCes();
149
149
  const workspaceDir = process.env.VELLUM_WORKSPACE_DIR;
150
150
  expect(workspaceDir).toBeDefined();
151
151
  const workspaceSocket = resolveIpcEndpoint("ces", {
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  import { credentialKey } from "@vellumai/credential-storage";
16
16
 
17
+ import { PLUGIN_NAME_ENV } from "../plugin-api/plugin-name-env.js";
17
18
  import {
18
19
  CredentialResolutionError,
19
20
  resolveCredential,
@@ -22,8 +23,10 @@ import { runInPluginContext } from "../plugins/plugin-execution-context.js";
22
23
  import * as secureKeys from "../security/secure-keys.js";
23
24
  import {
24
25
  _setMetadataPath,
26
+ type CredentialMetadata,
25
27
  upsertCredentialMetadata,
26
28
  } from "../tools/credentials/metadata-store.js";
29
+ import * as metadataStore from "../tools/credentials/metadata-store.js";
27
30
 
28
31
  // Real metadata store backed by a temp file (no module mocking — a mock.module
29
32
  // on metadata-store / secure-keys would replace the whole module namespace and
@@ -65,6 +68,7 @@ beforeEach(() => {
65
68
 
66
69
  afterEach(() => {
67
70
  getSpy.mockRestore();
71
+ delete process.env[PLUGIN_NAME_ENV];
68
72
  });
69
73
 
70
74
  afterAll(() => {
@@ -145,5 +149,98 @@ describe("resolveCredential", () => {
145
149
  ),
146
150
  ).resolves.toBe("proj-id");
147
151
  });
152
+
153
+ test("scopes resolution from VELLUM_PLUGIN_NAME when ALS is empty", async () => {
154
+ seedCredential("sms", "account_sid", "sid-secret");
155
+ process.env[PLUGIN_NAME_ENV] = "sms";
156
+ await expect(resolveCredential("sms/account_sid")).resolves.toBe(
157
+ "sid-secret",
158
+ );
159
+ });
160
+
161
+ test("blocks another service when identity comes from VELLUM_PLUGIN_NAME", async () => {
162
+ seedCredential("openai", "api_key", "sk-secret");
163
+ process.env[PLUGIN_NAME_ENV] = "sms";
164
+ await expect(resolveCredential("openai/api_key")).rejects.toThrow(
165
+ /out of scope/,
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("live catalog on cache miss", () => {
171
+ function liveRecord(
172
+ service: string,
173
+ field: string,
174
+ ): CredentialMetadata {
175
+ return {
176
+ credentialId: `${service}-${field}`,
177
+ service,
178
+ field,
179
+ allowedTools: [],
180
+ allowedDomains: [],
181
+ createdAt: 1,
182
+ updatedAt: 1,
183
+ };
184
+ }
185
+
186
+ test("consults the live catalog when plugin context is set and the cache misses", async () => {
187
+ const liveSpy = spyOn(
188
+ metadataStore,
189
+ "listCredentialRecordsLive",
190
+ ).mockResolvedValue({
191
+ records: [liveRecord("sms", "account_sid")],
192
+ unreachable: false,
193
+ });
194
+ secureStore.set(credentialKey("sms", "account_sid"), "live-sid");
195
+ process.env[PLUGIN_NAME_ENV] = "sms";
196
+
197
+ try {
198
+ await expect(resolveCredential("sms/account_sid")).resolves.toBe(
199
+ "live-sid",
200
+ );
201
+ expect(liveSpy).toHaveBeenCalledTimes(1);
202
+ } finally {
203
+ liveSpy.mockRestore();
204
+ }
205
+ });
206
+
207
+ test("does not list live records when no plugin is in context", async () => {
208
+ const liveSpy = spyOn(
209
+ metadataStore,
210
+ "listCredentialRecordsLive",
211
+ ).mockResolvedValue({
212
+ records: [liveRecord("sms", "account_sid")],
213
+ unreachable: false,
214
+ });
215
+ secureStore.set(credentialKey("sms", "account_sid"), "live-sid");
216
+
217
+ try {
218
+ await expect(resolveCredential("sms/account_sid")).rejects.toThrow(
219
+ /Credential not found/,
220
+ );
221
+ expect(liveSpy).not.toHaveBeenCalled();
222
+ } finally {
223
+ liveSpy.mockRestore();
224
+ }
225
+ });
226
+
227
+ test("reports an unreachable live catalog instead of not found", async () => {
228
+ const liveSpy = spyOn(
229
+ metadataStore,
230
+ "listCredentialRecordsLive",
231
+ ).mockResolvedValue({
232
+ records: [],
233
+ unreachable: true,
234
+ });
235
+ process.env[PLUGIN_NAME_ENV] = "sms";
236
+
237
+ try {
238
+ await expect(resolveCredential("sms/account_sid")).rejects.toThrow(
239
+ /unreachable/,
240
+ );
241
+ } finally {
242
+ liveSpy.mockRestore();
243
+ }
244
+ });
148
245
  });
149
246
  });
@@ -67,6 +67,19 @@ beforeAll(async () => {
67
67
  `export async function run() {
68
68
  await new Promise(resolve => setTimeout(resolve, 120_000));
69
69
  return { content: 'should not reach', isError: false };
70
+ }`,
71
+ "utf-8",
72
+ );
73
+
74
+ await writeFile(
75
+ join(tempDir, "plugin-name-echo.ts"),
76
+ `export async function run() {
77
+ return {
78
+ content: JSON.stringify({
79
+ pluginName: process.env.VELLUM_PLUGIN_NAME ?? null,
80
+ }),
81
+ isError: false,
82
+ };
70
83
  }`,
71
84
  "utf-8",
72
85
  );
@@ -113,6 +126,25 @@ describe("runSkillToolScript sandbox — success", () => {
113
126
  expect(parsed.workingDir).toBe("/my/project");
114
127
  expect(parsed.conversationId).toBe("sess-42");
115
128
  }, 15_000);
129
+
130
+ test("sets VELLUM_PLUGIN_NAME for a plugin-owned skill tool", async () => {
131
+ const result = await runSkillToolScript(
132
+ tempDir,
133
+ "plugin-name-echo.ts",
134
+ {},
135
+ makeContext(),
136
+ {
137
+ target: "sandbox",
138
+ pluginOwner: "psk-demo",
139
+ },
140
+ );
141
+
142
+ expect(result.isError).toBe(false);
143
+ const parsed = JSON.parse(result.content) as {
144
+ pluginName: string | null;
145
+ };
146
+ expect(parsed.pluginName).toBe("psk-demo");
147
+ }, 15_000);
116
148
  });
117
149
 
118
150
  // ---------------------------------------------------------------------------
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { MCP_GLOBAL_MAX_TOOLS } from "../mcp.js";
4
+ import { ToolsConfigSchema } from "../tools.js";
5
+
6
+ describe("ToolsConfigSchema.mcpGlobalMaxTools", () => {
7
+ test("omits the override so the shipped MCP cap remains in force", () => {
8
+ const parsed = ToolsConfigSchema.parse({});
9
+ expect(parsed.mcpGlobalMaxTools).toBeUndefined();
10
+ expect(parsed.exclude).toEqual([]);
11
+ });
12
+
13
+ test("accepts a workspace override", () => {
14
+ expect(
15
+ ToolsConfigSchema.parse({ mcpGlobalMaxTools: 80 }).mcpGlobalMaxTools,
16
+ ).toBe(80);
17
+ expect(
18
+ ToolsConfigSchema.parse({
19
+ mcpGlobalMaxTools: MCP_GLOBAL_MAX_TOOLS,
20
+ }).mcpGlobalMaxTools,
21
+ ).toBe(MCP_GLOBAL_MAX_TOOLS);
22
+ expect(
23
+ ToolsConfigSchema.parse({ mcpGlobalMaxTools: 1000 }).mcpGlobalMaxTools,
24
+ ).toBe(1000);
25
+ });
26
+
27
+ test("rejects zero and fractions", () => {
28
+ expect(() => ToolsConfigSchema.parse({ mcpGlobalMaxTools: 0 })).toThrow();
29
+ expect(() => ToolsConfigSchema.parse({ mcpGlobalMaxTools: 1.5 })).toThrow();
30
+ });
31
+ });
@@ -18,7 +18,8 @@ export const PLUGIN_MCP_RISK_LEVEL = "low" as const;
18
18
  export const MCP_MAX_TOOLS_PER_SERVER = 20;
19
19
 
20
20
  /**
21
- * Cap on tools registered across every MCP server. Selection is a
21
+ * Cap on tools registered across every MCP server when the workspace
22
+ * does not set `tools.mcpGlobalMaxTools` in config.json. Selection is a
22
23
  * deterministic round-robin by server id (see `mcp/tool-caps.ts`), so a
23
24
  * later server is not emptied just because earlier ones filled the budget.
24
25
  */
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { MCP_GLOBAL_MAX_TOOLS } from "./mcp.js";
4
+
3
5
  export const ToolsConfigSchema = z
4
6
  .object({
5
7
  exclude: z
@@ -8,6 +10,14 @@ export const ToolsConfigSchema = z
8
10
  .describe(
9
11
  "Tool names to suppress. Excluded tools are not sent to the LLM. Names match `ToolDefinition.name` exactly (e.g. `bash`, `mcp__server__tool`).",
10
12
  ),
13
+ mcpGlobalMaxTools: z
14
+ .number({ error: "tools.mcpGlobalMaxTools must be a number" })
15
+ .int("tools.mcpGlobalMaxTools must be an integer")
16
+ .min(1, "tools.mcpGlobalMaxTools must be >= 1")
17
+ .optional()
18
+ .describe(
19
+ `Override the code-owned cap on MCP tools registered across every server. Omit to use the shipped default of ${MCP_GLOBAL_MAX_TOOLS}. Selection is a fair round-robin by server id.`,
20
+ ),
11
21
  })
12
22
  .describe("Tool visibility configuration");
13
23
 
@@ -225,10 +225,6 @@ export interface SkillToolManifestMeta {
225
225
 
226
226
  // ─── Helpers ─────────────────────────────────────────────────────────────────
227
227
 
228
- function getSkillsDir(): string {
229
- return getWorkspaceSkillsDir();
230
- }
231
-
232
228
  export function getBundledSkillsDir(): string {
233
229
  const dir = import.meta.dir;
234
230
 
@@ -1114,7 +1110,7 @@ export function loadSkillCatalog(
1114
1110
  }
1115
1111
 
1116
1112
  // Load managed (user) skills, which take precedence over bundled skills with the same ID
1117
- const skillsDir = getSkillsDir();
1113
+ const skillsDir = getWorkspaceSkillsDir();
1118
1114
  const directories = discoverSkillDirectories(skillsDir);
1119
1115
 
1120
1116
  for (const directory of directories) {
@@ -1354,7 +1350,7 @@ function loadSkillDefinition(skill: SkillSummary): SkillLookupResult {
1354
1350
  } else {
1355
1351
  loaded = readSkillFromDirectory(
1356
1352
  skill.directoryPath,
1357
- getSkillsDir(),
1353
+ getWorkspaceSkillsDir(),
1358
1354
  skill.source,
1359
1355
  );
1360
1356
  }
@@ -53,9 +53,7 @@ export type DiscoveryResult = ManagedDiscoverySuccess | DiscoveryFailure;
53
53
  * processes can each connect. The actual connection is made later by
54
54
  * `CesProcessManager.start()`.
55
55
  */
56
- export function discoverManagedCes():
57
- | ManagedDiscoverySuccess
58
- | DiscoveryFailure {
56
+ export function discoverCes(): DiscoveryResult {
59
57
  const socketPath = getCesSocketPath();
60
58
 
61
59
  if (!isNamedPipePath(socketPath) && !existsSync(socketPath)) {
@@ -68,14 +66,6 @@ export function discoverManagedCes():
68
66
  return { mode: "managed", socketPath };
69
67
  }
70
68
 
71
- /**
72
- * Discover CES for the current process. Local and managed topologies use
73
- * the same bootstrap socket path.
74
- */
75
- export function discoverCes(): DiscoveryResult {
76
- return discoverManagedCes();
77
- }
78
-
79
69
  /** How long to poll for the CES socket before giving up. */
80
70
  const MANAGED_DISCOVERY_TIMEOUT_MS = 3_000;
81
71
 
@@ -190,12 +190,22 @@ export class ConfigWatcher {
190
190
  if (this.suppressReload) {
191
191
  return;
192
192
  }
193
+ const prevMcpGlobalMax = this.lastConfig?.tools.mcpGlobalMaxTools;
193
194
  try {
194
195
  const changed = await this.refreshConfigFromSources();
195
196
  if (changed) {
196
197
  evictConversationsForReload();
197
198
  refreshAuthenticatedApiRateLimit();
198
199
  publishConfigChanged();
200
+ const nextMcpGlobalMax = getConfig().tools.mcpGlobalMaxTools;
201
+ if (prevMcpGlobalMax !== nextMcpGlobalMax) {
202
+ reloadMcpServers().catch((err: unknown) => {
203
+ log.error(
204
+ { err },
205
+ "MCP reload after tools.mcpGlobalMaxTools change failed",
206
+ );
207
+ });
208
+ }
199
209
  }
200
210
  } catch (err) {
201
211
  log.error(
@@ -463,6 +463,7 @@ export function projectSkillTools(
463
463
  skill.directoryPath,
464
464
  currentHash,
465
465
  skill.bundled,
466
+ skill.owner?.kind === "plugin" ? skill.owner.id : undefined,
466
467
  );
467
468
 
468
469
  if (tools.length > 0) {
@@ -11,6 +11,13 @@ import type { ResolvedMcpConfig } from "../../config/schemas/mcp.js";
11
11
 
12
12
  const toolsByServer = new Map<string, Array<{ name: string }>>();
13
13
  const connectDelays = new Map<string, number>();
14
+ let mcpGlobalMaxTools: number | undefined;
15
+
16
+ mock.module("../../config/loader.js", () => ({
17
+ getConfig: () => ({
18
+ tools: { exclude: [], mcpGlobalMaxTools },
19
+ }),
20
+ }));
14
21
 
15
22
  mock.module("../client.js", () => ({
16
23
  McpClient: class {
@@ -60,6 +67,7 @@ describe("McpServerManager tool selection", () => {
60
67
  beforeEach(() => {
61
68
  toolsByServer.clear();
62
69
  connectDelays.clear();
70
+ mcpGlobalMaxTools = undefined;
63
71
  });
64
72
 
65
73
  test("later servers keep tools when eight servers exceed the global cap", async () => {
@@ -108,4 +116,29 @@ describe("McpServerManager tool selection", () => {
108
116
  ).toBe(true);
109
117
  await manager.stop();
110
118
  });
119
+
120
+ test("a workspace global-max override raises how many tools are kept", async () => {
121
+ const ids = Array.from(
122
+ { length: 8 },
123
+ (_, i) => `server-${String(i + 1).padStart(2, "0")}`,
124
+ );
125
+ for (const id of ids) {
126
+ toolsByServer.set(
127
+ id,
128
+ Array.from({ length: 10 }, (_, i) => ({ name: `tool_${i}` })),
129
+ );
130
+ }
131
+
132
+ mcpGlobalMaxTools = 80;
133
+ const manager = new McpServerManager();
134
+ const started = await manager.start(configWith(ids));
135
+
136
+ expect(started.discoveredToolCount).toBe(80);
137
+ expect(started.keptToolCount).toBe(80);
138
+ expect(started.droppedToolCount).toBe(0);
139
+ expect(started.servers.every((server) => server.tools.length === 10)).toBe(
140
+ true,
141
+ );
142
+ await manager.stop();
143
+ });
111
144
  });
@@ -10,7 +10,7 @@ import {
10
10
  MCP_GLOBAL_MAX_TOOLS,
11
11
  MCP_MAX_TOOLS_PER_SERVER,
12
12
  } from "../../config/schemas/mcp.js";
13
- import { applyMcpToolCaps } from "../tool-caps.js";
13
+ import { applyMcpToolCaps, resolveMcpGlobalMaxTools } from "../tool-caps.js";
14
14
 
15
15
  function toolsNamed(prefix: string, count: number): string[] {
16
16
  return Array.from({ length: count }, (_, i) => `${prefix}-tool-${i}`);
@@ -104,4 +104,31 @@ describe("applyMcpToolCaps", () => {
104
104
  4,
105
105
  );
106
106
  });
107
+
108
+ test("honors a workspace global-max override", () => {
109
+ const servers = Array.from({ length: 8 }, (_, i) => ({
110
+ serverId: `server-${String(i + 1).padStart(2, "0")}`,
111
+ tools: toolsNamed(`s${i + 1}`, 10),
112
+ }));
113
+
114
+ const result = applyMcpToolCaps(servers, { globalMax: 80, perServerMax: 20 });
115
+
116
+ expect(result.keptToolCount).toBe(80);
117
+ expect(result.droppedToolCount).toBe(0);
118
+ expect(result.servers.every((server) => server.tools.length === 10)).toBe(
119
+ true,
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("resolveMcpGlobalMaxTools", () => {
125
+ test("uses the shipped default when the workspace omits the override", () => {
126
+ expect(resolveMcpGlobalMaxTools()).toBe(MCP_GLOBAL_MAX_TOOLS);
127
+ expect(resolveMcpGlobalMaxTools({})).toBe(MCP_GLOBAL_MAX_TOOLS);
128
+ });
129
+
130
+ test("uses tools.mcpGlobalMaxTools from config.json when set", () => {
131
+ expect(resolveMcpGlobalMaxTools({ mcpGlobalMaxTools: 80 })).toBe(80);
132
+ expect(resolveMcpGlobalMaxTools({ mcpGlobalMaxTools: 1 })).toBe(1);
133
+ });
107
134
  });
@@ -1,3 +1,4 @@
1
+ import { getConfig } from "../config/loader.js";
1
2
  import type {
2
3
  ResolvedMcpConfig,
3
4
  ResolvedMcpServerConfig,
@@ -6,6 +7,7 @@ import { getLogger } from "../util/logger.js";
6
7
  import { McpClient, type McpToolInfo } from "./client.js";
7
8
  import {
8
9
  applyMcpToolCaps,
10
+ resolveMcpGlobalMaxTools,
9
11
  truncatedServerIdsFromCaps,
10
12
  } from "./tool-caps.js";
11
13
 
@@ -78,6 +80,7 @@ export class McpServerManager {
78
80
  serverId: result.serverId,
79
81
  tools: result.tools,
80
82
  })),
83
+ { globalMax: resolveMcpGlobalMaxTools(getConfig().tools) },
81
84
  );
82
85
  const keptByServer = new Map(
83
86
  capped.servers.map((server) => [server.serverId, server.tools]),
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Code-owned MCP tool-count caps and the selection used when they bind.
3
3
  *
4
- * Caps live here, not in workspace config: migration 153 strips
5
- * `maxTools` / `globalMaxTools` from `config.json`. The numbers are a
6
- * context-window budget, not a permission gate. Allowlists and risk
7
- * still decide what a turn may call.
4
+ * The shipped global cap is {@link MCP_GLOBAL_MAX_TOOLS}. A workspace may
5
+ * raise or lower it with `tools.mcpGlobalMaxTools` in config.json. The
6
+ * per-server cap stays code-owned. These numbers are a context-window
7
+ * budget, not a permission gate. Allowlists and risk still decide what
8
+ * a turn may call.
8
9
  *
9
10
  * The global cap is applied by a deterministic round-robin across
10
11
  * servers sorted by id. Insertion order must not empty a later server
@@ -15,6 +16,7 @@ import {
15
16
  MCP_GLOBAL_MAX_TOOLS,
16
17
  MCP_MAX_TOOLS_PER_SERVER,
17
18
  } from "../config/schemas/mcp.js";
19
+ import type { ToolsConfig } from "../config/schemas/tools.js";
18
20
 
19
21
  export interface McpServerToolSet<T> {
20
22
  serverId: string;
@@ -39,6 +41,12 @@ export interface McpToolCapResult<T> {
39
41
  decisions: McpToolCapDecision[];
40
42
  }
41
43
 
44
+ export function resolveMcpGlobalMaxTools(
45
+ tools?: Pick<ToolsConfig, "mcpGlobalMaxTools">,
46
+ ): number {
47
+ return tools?.mcpGlobalMaxTools ?? MCP_GLOBAL_MAX_TOOLS;
48
+ }
49
+
42
50
  export function applyMcpToolCaps<T>(
43
51
  servers: ReadonlyArray<McpServerToolSet<T>>,
44
52
  options?: { globalMax?: number; perServerMax?: number },
@@ -167,19 +167,26 @@ describe("spawnMonitoringWorkerProcess", () => {
167
167
  }
168
168
  });
169
169
 
170
- test("reuses an already-running monitor without spawning", async () => {
170
+ test("spawns when the PID file names an unrelated live process", async () => {
171
171
  writeFileSync(pidPath, String(process.pid));
172
172
  let spawned = false;
173
173
  const restore = stubBunSpawn(() => {
174
174
  spawned = true;
175
- return { unref: () => {}, kill: () => {}, pid: 1, exited: neverExits() };
175
+ writeFileSync(pidPath, "4242");
176
+ return {
177
+ unref: () => {},
178
+ kill: () => {},
179
+ pid: 4242,
180
+ exited: neverExits(),
181
+ };
176
182
  });
177
183
  try {
178
184
  const result = await spawnMonitoringWorkerProcess({
179
- pidWaitTimeoutMs: 100,
185
+ pidWaitTimeoutMs: 1_000,
186
+ pidPollIntervalMs: 10,
180
187
  });
181
- expect(result).toEqual({ pid: process.pid, alreadyRunning: true });
182
- expect(spawned).toBe(false);
188
+ expect(result).toEqual({ pid: 4242, alreadyRunning: false });
189
+ expect(spawned).toBe(true);
183
190
  } finally {
184
191
  restore();
185
192
  }
@@ -201,6 +208,11 @@ describe("probeMonitoringWorker", () => {
201
208
  }
202
209
  });
203
210
 
211
+ test("reports not_running when the PID file names a live process that is not this worker", () => {
212
+ writeFileSync(pidPath, String(process.pid));
213
+ expect(probeMonitoringWorker()).toEqual({ status: "not_running" });
214
+ });
215
+
204
216
  test("reports running (not throws) when the process exists but is not signalable (EPERM)", () => {
205
217
  writeFileSync(pidPath, "4321");
206
218
  const restore = stubProcessKill(new Set(), new Set([4321]));
@@ -19,15 +19,21 @@ import {
19
19
  type WorkerProcessStatus,
20
20
  } from "../util/worker-process.js";
21
21
 
22
+ const MONITORING_WORKER_ENTRY = new URL("./worker.ts", import.meta.url);
23
+
22
24
  const log = getLogger("monitoring-control");
23
25
 
24
26
  /**
25
27
  * Read the PID file and report liveness. A missing or malformed file reports
26
- * not_running; a file pointing at a dead process is cleaned up and reported as
27
- * not_running.
28
+ * not_running; a file pointing at a dead process, or at a live process that
29
+ * is not this worker, is cleaned up and reported as not_running.
28
30
  */
29
31
  export function probeMonitoringWorker(): WorkerProcessStatus {
30
- return probeWorkerPidFile(getMonitoringPidPath());
32
+ return probeWorkerPidFile(
33
+ getMonitoringPidPath(),
34
+ MONITORING_WORKER_ENTRY,
35
+ "monitoring",
36
+ );
31
37
  }
32
38
 
33
39
  export class MonitoringWorkerSpawnError extends WorkerProcessSpawnError {}
@@ -44,7 +50,7 @@ export async function spawnMonitoringWorkerProcess(
44
50
  try {
45
51
  return await spawnWorkerProcess({
46
52
  pidPath: getMonitoringPidPath(),
47
- entry: new URL("./worker.ts", import.meta.url),
53
+ entry: MONITORING_WORKER_ENTRY,
48
54
  packagedEntry: "monitoring",
49
55
  workerLabel: "Resource monitor",
50
56
  options: opts,
@@ -63,7 +69,11 @@ export async function spawnMonitoringWorkerProcess(
63
69
  * (e.g. EPERM) — a not-running monitor is a no-op.
64
70
  */
65
71
  export function stopMonitoringWorkerProcess(): WorkerProcessStatus {
66
- return stopWorkerProcess(getMonitoringPidPath());
72
+ return stopWorkerProcess(
73
+ getMonitoringPidPath(),
74
+ MONITORING_WORKER_ENTRY,
75
+ "monitoring",
76
+ );
67
77
  }
68
78
 
69
79
  /**