@vellumai/assistant 0.12.2 → 0.12.3-staging.1

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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -15,7 +15,21 @@
15
15
 
16
16
  import { afterEach, describe, expect, jest, mock, test } from "bun:test";
17
17
 
18
- const start = jest.fn(async () => [] as unknown[]);
18
+ function startResult(servers: unknown[] = []) {
19
+ return {
20
+ servers,
21
+ configuredServerCount: servers.length,
22
+ connectedServerCount: servers.length,
23
+ errorServerCount: 0,
24
+ needsAuthServerCount: 0,
25
+ discoveredToolCount: servers.length,
26
+ keptToolCount: servers.length,
27
+ droppedToolCount: 0,
28
+ truncatedServerIds: [] as string[],
29
+ };
30
+ }
31
+
32
+ const start = jest.fn(async () => startResult());
19
33
 
20
34
  mock.module("../manager.js", () => ({
21
35
  getMcpServerManager: () => ({
@@ -88,20 +102,24 @@ afterEach(() => {
88
102
 
89
103
  describe("startConfiguredMcpServers", () => {
90
104
  test("registers the tools a server reports, namespaced by server id", async () => {
91
- start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
105
+ start.mockResolvedValueOnce(
106
+ startResult([serverReporting("fastmail", "search_email")]),
107
+ );
92
108
 
93
109
  const registered = await startConfiguredMcpServers(
94
110
  workspaceConfigWith("fastmail") as never,
95
111
  );
96
112
 
97
- expect(registered).toBe(1);
113
+ expect(registered.registeredToolCount).toBe(1);
114
+ expect(registered.connectedServerCount).toBe(1);
98
115
  expect(getTool("mcp__fastmail__search_email")).toBeDefined();
99
116
  });
100
117
 
101
118
  test("does nothing when no servers are configured", async () => {
102
119
  const registered = await startConfiguredMcpServers(undefined);
103
120
 
104
- expect(registered).toBe(0);
121
+ expect(registered.registeredToolCount).toBe(0);
122
+ expect(registered.configuredServerCount).toBe(0);
105
123
  expect(start).not.toHaveBeenCalled();
106
124
  });
107
125
 
@@ -112,13 +130,16 @@ describe("startConfiguredMcpServers", () => {
112
130
  workspaceConfigWith("fastmail") as never,
113
131
  );
114
132
 
115
- expect(registered).toBe(0);
133
+ expect(registered.registeredToolCount).toBe(0);
134
+ expect(registered.configuredServerCount).toBe(1);
116
135
  });
117
136
  });
118
137
 
119
138
  describe("restartConfiguredMcpServers", () => {
120
139
  test("drops tools the reconnected set no longer reports", async () => {
121
- start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
140
+ start.mockResolvedValueOnce(
141
+ startResult([serverReporting("fastmail", "search_email")]),
142
+ );
122
143
  await startConfiguredMcpServers(workspaceConfigWith("fastmail") as never);
123
144
  expect(getTool("mcp__fastmail__search_email")).toBeDefined();
124
145
 
@@ -135,7 +156,9 @@ describe("restartConfiguredMcpServers", () => {
135
156
  });
136
157
 
137
158
  test("reconnects from the config on disk rather than the previous set", async () => {
138
- start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
159
+ start.mockResolvedValueOnce(
160
+ startResult([serverReporting("fastmail", "search_email")]),
161
+ );
139
162
  await startConfiguredMcpServers(workspaceConfigWith("fastmail") as never);
140
163
  start.mockClear();
141
164
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The global MCP tool cap used to consume the budget in insertion order,
3
+ * so later servers could lose every tool when earlier ones filled the
4
+ * 50-tool ceiling. Selection is a deterministic round-robin by server id.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import {
10
+ MCP_GLOBAL_MAX_TOOLS,
11
+ MCP_MAX_TOOLS_PER_SERVER,
12
+ } from "../../config/schemas/mcp.js";
13
+ import { applyMcpToolCaps } from "../tool-caps.js";
14
+
15
+ function toolsNamed(prefix: string, count: number): string[] {
16
+ return Array.from({ length: count }, (_, i) => `${prefix}-tool-${i}`);
17
+ }
18
+
19
+ function setsOf(servers: Array<{ serverId: string; tools: string[] }>) {
20
+ return new Map(servers.map((server) => [server.serverId, server.tools]));
21
+ }
22
+
23
+ describe("applyMcpToolCaps", () => {
24
+ test("keeps every tool when the discovered set is under both caps", () => {
25
+ const result = applyMcpToolCaps([
26
+ { serverId: "alpha", tools: toolsNamed("alpha", 3) },
27
+ { serverId: "beta", tools: toolsNamed("beta", 2) },
28
+ ]);
29
+
30
+ expect(result.keptToolCount).toBe(5);
31
+ expect(result.droppedToolCount).toBe(0);
32
+ expect(result.servers.map((server) => server.tools.length)).toEqual([3, 2]);
33
+ });
34
+
35
+ test("applies the per-server cap before the global budget", () => {
36
+ const result = applyMcpToolCaps(
37
+ [{ serverId: "busy", tools: toolsNamed("busy", 8) }],
38
+ { perServerMax: 3, globalMax: 50 },
39
+ );
40
+
41
+ expect(result.servers[0]?.tools).toEqual(toolsNamed("busy", 3));
42
+ expect(result.decisions[0]?.droppedByPerServerCap).toBe(5);
43
+ expect(result.decisions[0]?.droppedByGlobalCap).toBe(0);
44
+ });
45
+
46
+ test("eight servers totaling about 50 tools do not starve later servers", () => {
47
+ const servers = Array.from({ length: 8 }, (_, i) => ({
48
+ serverId: `server-${String(i + 1).padStart(2, "0")}`,
49
+ tools: toolsNamed(`s${i + 1}`, 10),
50
+ }));
51
+
52
+ const result = applyMcpToolCaps(servers, {
53
+ perServerMax: MCP_MAX_TOOLS_PER_SERVER,
54
+ globalMax: MCP_GLOBAL_MAX_TOOLS,
55
+ });
56
+
57
+ expect(result.discoveredToolCount).toBe(80);
58
+ expect(result.keptToolCount).toBe(MCP_GLOBAL_MAX_TOOLS);
59
+ expect(
60
+ result.servers.every((server) => server.tools.length > 0),
61
+ "Every connected server keeps at least one tool under a 50-tool global cap.",
62
+ ).toBe(true);
63
+ expect(result.servers[5]?.tools.length).toBeGreaterThan(0);
64
+ expect(result.servers[6]?.tools.length).toBeGreaterThan(0);
65
+ expect(result.servers[7]?.tools.length).toBeGreaterThan(0);
66
+ });
67
+
68
+ test("reordering servers does not change which tools are kept", () => {
69
+ const forward = Array.from({ length: 8 }, (_, i) => ({
70
+ serverId: `server-${String(i + 1).padStart(2, "0")}`,
71
+ tools: toolsNamed(`s${i + 1}`, 10),
72
+ }));
73
+ const reversed = [...forward].reverse();
74
+
75
+ const a = applyMcpToolCaps(forward, { globalMax: 50, perServerMax: 20 });
76
+ const b = applyMcpToolCaps(reversed, { globalMax: 50, perServerMax: 20 });
77
+
78
+ expect(setsOf(a.servers)).toEqual(setsOf(b.servers));
79
+ expect(
80
+ a.servers.every((server) => server.tools.length > 0),
81
+ ).toBe(true);
82
+ expect(
83
+ b.servers.every((server) => server.tools.length > 0),
84
+ ).toBe(true);
85
+ });
86
+
87
+ test("does not use insertion order to empty a later server", () => {
88
+ const result = applyMcpToolCaps(
89
+ [
90
+ { serverId: "early", tools: toolsNamed("early", 10) },
91
+ { serverId: "mid", tools: toolsNamed("mid", 10) },
92
+ { serverId: "late", tools: toolsNamed("late", 10) },
93
+ ],
94
+ { globalMax: 12, perServerMax: 20 },
95
+ );
96
+
97
+ expect(result.servers.find((s) => s.serverId === "late")?.tools.length).toBe(
98
+ 4,
99
+ );
100
+ expect(result.servers.find((s) => s.serverId === "early")?.tools.length).toBe(
101
+ 4,
102
+ );
103
+ expect(result.servers.find((s) => s.serverId === "mid")?.tools.length).toBe(
104
+ 4,
105
+ );
106
+ });
107
+ });
@@ -1,11 +1,13 @@
1
- import {
2
- MCP_GLOBAL_MAX_TOOLS,
3
- MCP_MAX_TOOLS_PER_SERVER,
4
- type ResolvedMcpConfig,
5
- type ResolvedMcpServerConfig,
1
+ import type {
2
+ ResolvedMcpConfig,
3
+ ResolvedMcpServerConfig,
6
4
  } from "../config/schemas/mcp.js";
7
5
  import { getLogger } from "../util/logger.js";
8
6
  import { McpClient, type McpToolInfo } from "./client.js";
7
+ import {
8
+ applyMcpToolCaps,
9
+ truncatedServerIdsFromCaps,
10
+ } from "./tool-caps.js";
9
11
 
10
12
  const log = getLogger("mcp-manager");
11
13
 
@@ -15,6 +17,18 @@ export interface McpServerToolInfo {
15
17
  tools: McpToolInfo[];
16
18
  }
17
19
 
20
+ export interface McpStartResult {
21
+ servers: McpServerToolInfo[];
22
+ configuredServerCount: number;
23
+ connectedServerCount: number;
24
+ errorServerCount: number;
25
+ needsAuthServerCount: number;
26
+ discoveredToolCount: number;
27
+ keptToolCount: number;
28
+ droppedToolCount: number;
29
+ truncatedServerIds: string[];
30
+ }
31
+
18
32
  export type McpConnectionState =
19
33
  | "connecting"
20
34
  | "connected"
@@ -36,121 +50,174 @@ export class McpServerManager {
36
50
  this.onUnexpectedClose = handler;
37
51
  }
38
52
 
39
- async start(config: ResolvedMcpConfig): Promise<McpServerToolInfo[]> {
40
- const results: McpServerToolInfo[] = [];
53
+ async start(config: ResolvedMcpConfig): Promise<McpStartResult> {
54
+ const entries = Object.entries(config.servers);
55
+ log.info(
56
+ { configuredServerCount: entries.length },
57
+ "Starting configured MCP servers",
58
+ );
59
+ console.log(`[MCP] Starting ${entries.length} server(s)...`);
41
60
 
42
- console.log(
43
- `[MCP] Starting ${Object.keys(config.servers).length} server(s)...`,
61
+ const started = await Promise.all(
62
+ entries.map(([serverId, serverConfig]) =>
63
+ this.startOne(serverId, serverConfig),
64
+ ),
44
65
  );
45
- for (const [serverId, serverConfig] of Object.entries(config.servers)) {
46
- this.connectionStates.set(serverId, {
47
- source: serverConfig.source,
48
- state: "connecting",
49
- });
50
- try {
51
- console.log(
52
- `[MCP] Starting server "${serverId}" (transport: ${serverConfig.transport.type})`,
53
- );
54
- if (
55
- serverConfig.transport.type === "sse" ||
56
- serverConfig.transport.type === "streamable-http"
57
- ) {
58
- log.debug(
59
- { serverId },
60
- "HTTP transport: OAuth provider will be available if server requires authentication",
61
- );
62
- }
63
- const client = new McpClient(serverId, serverConfig, () => {
64
- if (this.clients.get(serverId) !== client) {
65
- return;
66
- }
67
- this.connectionStates.set(serverId, {
68
- source: serverConfig.source,
69
- state: "error",
70
- });
71
- this.onUnexpectedClose?.();
72
- });
73
- await client.connect(serverConfig.transport);
74
-
75
- if (!client.isConnected) {
76
- this.connectionStates.set(serverId, {
77
- source: serverConfig.source,
78
- state: client.lastError ? "error" : "needs-auth",
79
- });
80
- continue;
81
- }
66
+ const connected = started.filter(
67
+ (result): result is McpServerToolInfo => result != null,
68
+ );
69
+ const errorServerCount = [...this.connectionStates.values()].filter(
70
+ (entry) => entry.state === "error",
71
+ ).length;
72
+ const needsAuthServerCount = [...this.connectionStates.values()].filter(
73
+ (entry) => entry.state === "needs-auth",
74
+ ).length;
82
75
 
83
- this.clients.set(serverId, client);
76
+ const capped = applyMcpToolCaps(
77
+ connected.map((result) => ({
78
+ serverId: result.serverId,
79
+ tools: result.tools,
80
+ })),
81
+ );
82
+ const keptByServer = new Map(
83
+ capped.servers.map((server) => [server.serverId, server.tools]),
84
+ );
85
+ const results = connected.map((result) => ({
86
+ ...result,
87
+ tools: keptByServer.get(result.serverId) ?? [],
88
+ }));
89
+ const truncatedServerIds = truncatedServerIdsFromCaps(capped.decisions);
84
90
 
85
- let tools = await client.listTools();
86
- if (!client.isConnected || this.clients.get(serverId) !== client) {
87
- this.clients.delete(serverId);
88
- this.connectionStates.set(serverId, {
89
- source: serverConfig.source,
90
- state: "error",
91
- });
92
- continue;
93
- }
94
- log.info(
95
- { serverId, toolCount: tools.length },
96
- "MCP server tools discovered",
97
- );
91
+ if (capped.droppedToolCount > 0) {
92
+ log.warn(
93
+ {
94
+ configuredServerCount: entries.length,
95
+ connectedServerCount: connected.length,
96
+ errorServerCount,
97
+ needsAuthServerCount,
98
+ discoveredToolCount: capped.discoveredToolCount,
99
+ keptToolCount: capped.keptToolCount,
100
+ droppedToolCount: capped.droppedToolCount,
101
+ globalCap: capped.globalCap,
102
+ perServerCap: capped.perServerCap,
103
+ truncatedServerIds,
104
+ decisions: capped.decisions.filter(
105
+ (decision) =>
106
+ decision.droppedByPerServerCap > 0 ||
107
+ decision.droppedByGlobalCap > 0,
108
+ ),
109
+ },
110
+ "MCP tool caps dropped tools using fair per-server selection",
111
+ );
112
+ } else {
113
+ log.info(
114
+ {
115
+ configuredServerCount: entries.length,
116
+ connectedServerCount: connected.length,
117
+ errorServerCount,
118
+ needsAuthServerCount,
119
+ discoveredToolCount: capped.discoveredToolCount,
120
+ keptToolCount: capped.keptToolCount,
121
+ globalCap: capped.globalCap,
122
+ perServerCap: capped.perServerCap,
123
+ },
124
+ "MCP servers connected",
125
+ );
126
+ }
98
127
 
99
- if (tools.length > MCP_MAX_TOOLS_PER_SERVER) {
100
- log.warn(
101
- {
102
- serverId,
103
- discovered: tools.length,
104
- max: MCP_MAX_TOOLS_PER_SERVER,
105
- },
106
- "MCP server exceeded per-server tool cap, truncating",
107
- );
108
- tools = tools.slice(0, MCP_MAX_TOOLS_PER_SERVER);
128
+ return {
129
+ servers: results,
130
+ configuredServerCount: entries.length,
131
+ connectedServerCount: connected.length,
132
+ errorServerCount,
133
+ needsAuthServerCount,
134
+ discoveredToolCount: capped.discoveredToolCount,
135
+ keptToolCount: capped.keptToolCount,
136
+ droppedToolCount: capped.droppedToolCount,
137
+ truncatedServerIds,
138
+ };
139
+ }
140
+
141
+ private async startOne(
142
+ serverId: string,
143
+ serverConfig: ResolvedMcpServerConfig,
144
+ ): Promise<McpServerToolInfo | null> {
145
+ this.connectionStates.set(serverId, {
146
+ source: serverConfig.source,
147
+ state: "connecting",
148
+ });
149
+ try {
150
+ console.log(
151
+ `[MCP] Starting server "${serverId}" (transport: ${serverConfig.transport.type})`,
152
+ );
153
+ if (
154
+ serverConfig.transport.type === "sse" ||
155
+ serverConfig.transport.type === "streamable-http"
156
+ ) {
157
+ log.debug(
158
+ { serverId },
159
+ "HTTP transport: OAuth provider will be available if server requires authentication",
160
+ );
161
+ }
162
+ const client = new McpClient(serverId, serverConfig, () => {
163
+ if (this.clients.get(serverId) !== client) {
164
+ return;
109
165
  }
166
+ this.connectionStates.set(serverId, {
167
+ source: serverConfig.source,
168
+ state: "error",
169
+ });
170
+ this.onUnexpectedClose?.();
171
+ });
172
+ await client.connect(serverConfig.transport);
110
173
 
111
- results.push({ serverId, serverConfig, tools });
174
+ if (!client.isConnected) {
112
175
  this.connectionStates.set(serverId, {
113
176
  source: serverConfig.source,
114
- state: "connected",
177
+ state: client.lastError ? "error" : "needs-auth",
115
178
  });
116
- } catch (err) {
179
+ return null;
180
+ }
181
+
182
+ this.clients.set(serverId, client);
183
+
184
+ const tools = await client.listTools();
185
+ if (!client.isConnected || this.clients.get(serverId) !== client) {
186
+ this.clients.delete(serverId);
117
187
  this.connectionStates.set(serverId, {
118
188
  source: serverConfig.source,
119
189
  state: "error",
120
190
  });
121
- console.error(`[MCP] Failed to connect to server "${serverId}":`, err);
122
- log.error({ err, serverId }, "Failed to connect to MCP server");
123
- // Clean up any partially-connected client
124
- const staleClient = this.clients.get(serverId);
125
- if (staleClient) {
126
- try {
127
- await staleClient.disconnect();
128
- } catch {
129
- /* ignore */
130
- }
131
- this.clients.delete(serverId);
132
- }
191
+ return null;
133
192
  }
134
- }
135
-
136
- const totalTools = results.reduce((sum, r) => sum + r.tools.length, 0);
137
- if (totalTools > MCP_GLOBAL_MAX_TOOLS) {
138
- log.warn(
139
- { totalTools, globalMax: MCP_GLOBAL_MAX_TOOLS },
140
- "Total MCP tools exceed the global cap, truncating",
193
+ log.info(
194
+ { serverId, toolCount: tools.length },
195
+ "MCP server tools discovered",
141
196
  );
142
- let remaining = MCP_GLOBAL_MAX_TOOLS;
143
- for (const result of results) {
144
- if (remaining <= 0) {
145
- result.tools = [];
146
- } else if (result.tools.length > remaining) {
147
- result.tools = result.tools.slice(0, remaining);
197
+
198
+ this.connectionStates.set(serverId, {
199
+ source: serverConfig.source,
200
+ state: "connected",
201
+ });
202
+ return { serverId, serverConfig, tools };
203
+ } catch (err) {
204
+ this.connectionStates.set(serverId, {
205
+ source: serverConfig.source,
206
+ state: "error",
207
+ });
208
+ console.error(`[MCP] Failed to connect to server "${serverId}":`, err);
209
+ log.error({ err, serverId }, "Failed to connect to MCP server");
210
+ const staleClient = this.clients.get(serverId);
211
+ if (staleClient) {
212
+ try {
213
+ await staleClient.disconnect();
214
+ } catch {
215
+ /* ignore */
148
216
  }
149
- remaining -= result.tools.length;
217
+ this.clients.delete(serverId);
150
218
  }
219
+ return null;
151
220
  }
152
-
153
- return results;
154
221
  }
155
222
 
156
223
  async stop(): Promise<void> {
@@ -20,8 +20,14 @@ import { registerMcpTools, unregisterAllMcpTools } from "../tools/registry.js";
20
20
  import { getLogger } from "../util/logger.js";
21
21
  import { buildEffectiveMcpConfig } from "./effective-config.js";
22
22
  import { getMcpServerManager, stopMcpServerManager } from "./manager.js";
23
+ import {
24
+ EMPTY_MCP_STARTUP_SNAPSHOT,
25
+ type McpStartupSnapshot,
26
+ } from "./tool-caps.js";
23
27
  import { loadWorkspaceMcpConfig } from "./workspace-mcp-config.js";
24
28
 
29
+ export type { McpStartupSnapshot } from "./tool-caps.js";
30
+
25
31
  const log = getLogger("mcp-startup");
26
32
 
27
33
  /**
@@ -39,23 +45,27 @@ const log = getLogger("mcp-startup");
39
45
  *
40
46
  * @param workspaceMcpConfig The projected workspace MCP config. Callers
41
47
  * that omit it read `/workspace/mcp.json`.
42
- * @returns The number of tools registered across all servers.
48
+ * @returns A count snapshot of configured/connected servers and the tools
49
+ * that reached this process's registry after caps.
43
50
  */
44
51
  export async function startConfiguredMcpServers(
45
52
  workspaceMcpConfig?: McpConfig,
46
- ): Promise<number> {
47
- let registered = 0;
53
+ ): Promise<McpStartupSnapshot> {
54
+ let snapshot: McpStartupSnapshot = EMPTY_MCP_STARTUP_SNAPSHOT;
48
55
  try {
49
56
  const mcpConfig = buildEffectiveMcpConfig(
50
57
  workspaceMcpConfig ?? loadWorkspaceMcpConfig(),
51
58
  );
52
- if (Object.keys(mcpConfig.servers).length === 0) {
53
- return 0;
59
+ const configuredServerCount = Object.keys(mcpConfig.servers).length;
60
+ snapshot = { ...EMPTY_MCP_STARTUP_SNAPSHOT, configuredServerCount };
61
+ if (configuredServerCount === 0) {
62
+ return snapshot;
54
63
  }
55
64
 
56
65
  const manager = getMcpServerManager();
57
- const serverToolInfos = await manager.start(mcpConfig);
58
- for (const { serverId, serverConfig, tools } of serverToolInfos) {
66
+ const started = await manager.start(mcpConfig);
67
+ let registered = 0;
68
+ for (const { serverId, serverConfig, tools } of started.servers) {
59
69
  const mcpTools = createMcpToolsFromServer(
60
70
  tools,
61
71
  serverId,
@@ -64,16 +74,26 @@ export async function startConfiguredMcpServers(
64
74
  );
65
75
  registered += registerMcpTools(serverId, mcpTools).length;
66
76
  }
77
+ snapshot = {
78
+ configuredServerCount: started.configuredServerCount,
79
+ connectedServerCount: started.connectedServerCount,
80
+ discoveredToolCount: started.discoveredToolCount,
81
+ registeredToolCount: registered,
82
+ droppedToolCount: started.droppedToolCount,
83
+ truncatedServerIds: started.truncatedServerIds,
84
+ errorServerCount: started.errorServerCount,
85
+ needsAuthServerCount: started.needsAuthServerCount,
86
+ };
67
87
  } catch (err) {
68
88
  log.error(
69
- { err },
89
+ { err, ...snapshot },
70
90
  "MCP server initialization failed, continuing without MCP tools",
71
91
  );
72
- return registered;
92
+ return snapshot;
73
93
  }
74
94
 
75
- log.info({ toolCount: registered }, "MCP tools registered");
76
- return registered;
95
+ log.info(snapshot, "MCP tools registered");
96
+ return snapshot;
77
97
  }
78
98
 
79
99
  /**
@@ -91,9 +111,9 @@ export async function startConfiguredMcpServers(
91
111
  * The window between the teardown and the reconnect has no MCP tools
92
112
  * registered, so an `mcp__*` call landing inside it fails as an unknown tool.
93
113
  *
94
- * @returns The number of tools registered after the reconnect.
114
+ * @returns A count snapshot of the tools registered after the reconnect.
95
115
  */
96
- export async function restartConfiguredMcpServers(): Promise<number> {
116
+ export async function restartConfiguredMcpServers(): Promise<McpStartupSnapshot> {
97
117
  try {
98
118
  await stopMcpServerManager();
99
119
  } catch (err) {