@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.
- package/package.json +1 -1
- package/src/__tests__/config-watcher.test.ts +24 -0
- package/src/__tests__/credential-execution-client.test.ts +5 -5
- package/src/__tests__/plugin-api-resolve-credential.test.ts +97 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +32 -0
- package/src/config/schemas/__tests__/tools.test.ts +31 -0
- package/src/config/schemas/mcp.ts +2 -1
- package/src/config/schemas/tools.ts +10 -0
- package/src/config/skills.ts +2 -6
- package/src/credential-execution/executable-discovery.ts +1 -11
- package/src/daemon/config-watcher.ts +10 -0
- package/src/daemon/conversation-skill-tools.ts +1 -0
- package/src/mcp/__tests__/manager-tool-caps.test.ts +33 -0
- package/src/mcp/__tests__/tool-caps.test.ts +28 -1
- package/src/mcp/manager.ts +3 -0
- package/src/mcp/tool-caps.ts +12 -4
- package/src/monitoring/__tests__/control.test.ts +17 -5
- package/src/monitoring/control.ts +15 -5
- package/src/plugin-api/__tests__/plugin-name-env.test.ts +249 -0
- package/src/plugin-api/index.ts +5 -3
- package/src/plugin-api/plugin-name-env.ts +125 -0
- package/src/plugin-api/resolve-credential.ts +79 -20
- package/src/plugins/__tests__/active-plugin-env.test.ts +136 -0
- package/src/plugins/active-plugin-env.ts +51 -0
- package/src/plugins/defaults/memory/__tests__/worker-control.test.ts +19 -6
- package/src/plugins/defaults/memory/worker-control.ts +15 -5
- package/src/routes/control.ts +7 -5
- package/src/schedule/__tests__/worker-control.test.ts +36 -5
- package/src/schedule/worker-control.ts +15 -5
- package/src/skills/clawhub.ts +3 -8
- package/src/skills/managed-store.ts +1 -5
- package/src/tools/host-terminal/host-shell.ts +4 -1
- package/src/tools/skills/sandbox-runner.ts +11 -2
- package/src/tools/skills/skill-script-runner.ts +7 -0
- package/src/tools/skills/skill-tool-factory.ts +4 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +11 -0
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/terminal/shell.ts +2 -0
- package/src/util/__tests__/worker-process-command.test.ts +5 -4
- package/src/util/worker-process.ts +77 -35
package/package.json
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
package/src/config/skills.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
@@ -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
|
});
|
package/src/mcp/manager.ts
CHANGED
|
@@ -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]),
|
package/src/mcp/tool-caps.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Code-owned MCP tool-count caps and the selection used when they bind.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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("
|
|
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
|
-
|
|
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:
|
|
185
|
+
pidWaitTimeoutMs: 1_000,
|
|
186
|
+
pidPollIntervalMs: 10,
|
|
180
187
|
});
|
|
181
|
-
expect(result).toEqual({ pid:
|
|
182
|
-
expect(spawned).toBe(
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
72
|
+
return stopWorkerProcess(
|
|
73
|
+
getMonitoringPidPath(),
|
|
74
|
+
MONITORING_WORKER_ENTRY,
|
|
75
|
+
"monitoring",
|
|
76
|
+
);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/**
|