@bevel-software/platform-core-backend 0.29.0 → 0.31.0

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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -0,0 +1,521 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { setFlagsFromString } from 'node:v8';
3
+ import { runInNewContext } from 'node:vm';
4
+ import express from 'express';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import type { CallTemplate } from '@utcp/sdk';
11
+ import { McpService, type McpCaller } from '../mcp.service.js';
12
+ import { SpillStore } from '../../workspace/index.js';
13
+ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
14
+ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
15
+ import { toolDef } from '../../tool-helpers/index.js';
16
+ import type { AgentEventInput } from '../../audit/index.js';
17
+ import type { SurfaceCacheOptions } from '../surface-cache.js';
18
+ import { SharedDefinitions } from '../shared-definitions.js';
19
+
20
+ /**
21
+ * The hosted MCP server keeps one tool client per person, caller kind, key
22
+ * scope and catalog revision — asserted against the REAL service, a real
23
+ * loopback (the registry-driven KB manual, a catalog and a revision route), a
24
+ * real UTCP client and a real MCP client, with nothing faked but the data.
25
+ *
26
+ * A build is counted where it starts: the one `GET /api/agent/all-tools` it
27
+ * makes. Two requests that make one such call between them were served by
28
+ * one build.
29
+ *
30
+ * What each tool call carried is recorded at the loopback: the bearer, so a
31
+ * cached client can be seen to carry the CALLING request's credential and no
32
+ * other's.
33
+ */
34
+
35
+ let httpServer: HttpServer | undefined;
36
+ const cleanups: Array<() => Promise<void>> = [];
37
+
38
+ afterEach(async () => {
39
+ for (const c of cleanups.splice(0)) await c().catch(() => {});
40
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
41
+ httpServer = undefined;
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ interface HarnessOptions {
46
+ auditRecorder?: { record: (e: AgentEventInput) => void };
47
+ surfaceCache?: SurfaceCacheOptions;
48
+ /** A delay on the catalog fetch, standing in for a slow knowledge base. */
49
+ catalogDelayMs?: number;
50
+ /** Filler tools on the KB manual, and the size of each one's description (the heap test). */
51
+ fillerTools?: { count: number; descriptionBytes: number };
52
+ /** A minting internal-token service, for OAuth/session callers. */
53
+ mint?: () => string;
54
+ }
55
+
56
+ async function harness(opts: HarnessOptions = {}) {
57
+ const registry = new ToolRegistry();
58
+ registry.registerExternalTool(
59
+ toolDef({
60
+ name: 'read_file',
61
+ description: 'read a file',
62
+ path: '/api/agent/tools/read_file',
63
+ inputs: { type: 'object', properties: { path: { type: 'string' }, delayMs: { type: 'number' } } },
64
+ }),
65
+ );
66
+ registry.registerExternalTool(
67
+ toolDef({
68
+ name: 'edit_file',
69
+ description: 'edit a file',
70
+ path: '/api/agent/tools/edit_file',
71
+ write: true,
72
+ inputs: { type: 'object', properties: { path: { type: 'string' } } },
73
+ }),
74
+ );
75
+ const filler = opts.fillerTools;
76
+ for (let i = 0; i < (filler?.count ?? 0); i++) {
77
+ registry.registerExternalTool(
78
+ toolDef({
79
+ name: `filler_${i}`,
80
+ description: `filler ${i} `.padEnd(filler!.descriptionBytes, 'lorem ipsum dolor sit amet '),
81
+ path: `/api/agent/tools/filler_${i}`,
82
+ inputs: {
83
+ type: 'object',
84
+ properties: Object.fromEntries(
85
+ Array.from({ length: 12 }, (_, j) => [`field_${j}`, { type: 'string', description: `field ${j} of filler ${i}` }]),
86
+ ),
87
+ },
88
+ }),
89
+ );
90
+ }
91
+
92
+ const state = {
93
+ builds: 0,
94
+ revision: 'rev-1',
95
+ extraManuals: [] as CallTemplate[],
96
+ /** Bearers the loopback refuses, as a revoked key is. */
97
+ refused: new Set<string>(),
98
+ /** Every tool call the loopback answered: which tool, with which bearer. */
99
+ calls: [] as { tool: string; bearer: string; path?: string }[],
100
+ base: '',
101
+ };
102
+ const bearerOf = (req: express.Request) => (req.headers.authorization ?? '').replace(/^Bearer /, '');
103
+
104
+ const app = express();
105
+ app.use(express.json());
106
+ app.get('/api/agent/catalog-revision', (_req, res) => {
107
+ res.json({ revision: state.revision, tools: state.extraManuals.length, skills: 0 });
108
+ });
109
+ app.get('/api/agent/all-tools', async (_req, res) => {
110
+ state.builds += 1;
111
+ if (opts.catalogDelayMs) await new Promise((r) => setTimeout(r, opts.catalogDelayMs));
112
+ res.json({ manuals: state.extraManuals });
113
+ });
114
+ for (const tool of ['read_file', 'edit_file']) {
115
+ app.post(`/api/agent/tools/${tool}`, async (req, res) => {
116
+ const bearer = bearerOf(req);
117
+ if (state.refused.has(bearer)) {
118
+ res.status(401).json({ error: 'This connection key was revoked.' });
119
+ return;
120
+ }
121
+ const body = (req.body ?? {}) as { path?: string; delayMs?: number };
122
+ if (body.delayMs) await new Promise((r) => setTimeout(r, body.delayMs));
123
+ state.calls.push({ tool, bearer, path: body.path });
124
+ res.json({ tool, path: body.path, bearer });
125
+ });
126
+ }
127
+ app.get('/api/test/late-manual', (_req, res) =>
128
+ res.json({
129
+ utcp_version: '1.1.0',
130
+ manual_version: '1.0.0',
131
+ tools: [
132
+ {
133
+ name: 'late_arrival',
134
+ description: 'released after the first request',
135
+ inputs: { type: 'object', properties: {} },
136
+ outputs: { type: 'object', properties: {} },
137
+ tags: [],
138
+ tool_call_template: {
139
+ call_template_type: 'http',
140
+ http_method: 'POST',
141
+ url: `${state.base}/api/agent/tools/read_file`,
142
+ content_type: 'application/json',
143
+ },
144
+ },
145
+ ],
146
+ }),
147
+ );
148
+ const noAuth: express.RequestHandler = (_req, _res, next) => next();
149
+ app.use('/api', createManualRoutes(registry, noAuth));
150
+ httpServer = await new Promise<HttpServer>((resolve) => {
151
+ const s = app.listen(0, () => resolve(s));
152
+ });
153
+ state.base = `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
154
+
155
+ const makeService = () =>
156
+ new McpService(
157
+ {
158
+ loopbackBaseUrl: state.base,
159
+ manualName: 'KNOWLEDGE_BASE',
160
+ spillStore: new SpillStore(join(tmpdir(), 'bevel-test-spills')),
161
+ publicFrontendUrl: 'http://localhost:5173',
162
+ surfaceCache: opts.surfaceCache,
163
+ },
164
+ undefined,
165
+ undefined,
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
167
+ opts.mint ? ({ mint: opts.mint } as any) : undefined,
168
+ undefined,
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ opts.auditRecorder as any,
171
+ );
172
+ return { state, mcp: makeService(), makeService };
173
+ }
174
+
175
+ /** One request's server, driven over an in-memory pair — the route builds one per HTTP request. */
176
+ async function connect(mcp: McpService, caller: Partial<McpCaller> = {}): Promise<Client> {
177
+ const server = await mcp.createRequestServer(
178
+ { userId: 'user-A', tokenId: 'key-1', connectionId: null, bearer: 'bevel_key_1', ...caller },
179
+ { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
180
+ );
181
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
182
+ await server.connect(serverTransport);
183
+ const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
184
+ await client.connect(clientTransport);
185
+ cleanups.push(async () => {
186
+ await client.close();
187
+ await server.close();
188
+ });
189
+ return client;
190
+ }
191
+
192
+ /** A tool result's JSON payload. */
193
+ function payload(res: Awaited<ReturnType<Client['callTool']>>): Record<string, unknown> {
194
+ const content = res.content as Array<{ type: string; text?: string }>;
195
+ return JSON.parse(content.find((c) => c.type === 'text')!.text!) as Record<string, unknown>;
196
+ }
197
+
198
+ async function readFile(client: Client, path: string, delayMs?: number) {
199
+ return client.callTool({ name: 'read_file', arguments: { body: { path, ...(delayMs ? { delayMs } : {}) } } });
200
+ }
201
+
202
+ const lateManual = (base: string): CallTemplate =>
203
+ ({ name: 'late_manual', call_template_type: 'http', url: `${base}/api/test/late-manual`, http_method: 'GET' }) as CallTemplate;
204
+
205
+ describe('McpService — one tool client per person and catalog revision', () => {
206
+ it('serves a second request of the same person from the first one\'s build, in the time of the listing alone', async () => {
207
+ const { state, mcp } = await harness({ catalogDelayMs: 400 });
208
+ const info = vi.spyOn(console, 'log').mockImplementation(() => {});
209
+
210
+ const first = await connect(mcp);
211
+ const coldStart = performance.now();
212
+ const coldList = (await first.listTools()).tools.map((t) => t.name);
213
+ const cold = performance.now() - coldStart;
214
+
215
+ const second = await connect(mcp);
216
+ const warmStart = performance.now();
217
+ const warmList = (await second.listTools()).tools.map((t) => t.name);
218
+ const warm = performance.now() - warmStart;
219
+
220
+ expect(warmList).toEqual(coldList);
221
+ expect(state.builds).toBe(1);
222
+ expect(cold).toBeGreaterThanOrEqual(400);
223
+ // No catalog fetch, no registration, no discovery: the listing alone. The
224
+ // build count above is the proof; the clock only has to tell a listing
225
+ // from a build, which pays the catalog's 400 ms before anything else, so
226
+ // the bound is that delay and not a guess at how fast CI lists.
227
+ expect(warm).toBeLessThan(400);
228
+ // The surface log shows one build for the two requests.
229
+ const surfaceLines = info.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('request surface:'));
230
+ expect(surfaceLines).toHaveLength(1);
231
+ expect(surfaceLines[0]).toContain('cache=miss');
232
+ });
233
+
234
+ it('answers a tool call on a warm client in the tool\'s own time', async () => {
235
+ const { state, mcp } = await harness({ catalogDelayMs: 400 });
236
+ await (await connect(mcp)).listTools();
237
+ const client = await connect(mcp);
238
+ const started = performance.now();
239
+ const res = await readFile(client, 'KnowledgeBase/Foo.md');
240
+ expect(payload(res)).toMatchObject({ path: 'KnowledgeBase/Foo.md' });
241
+ // Only the call is timed, against the 400 ms any rebuild would pay first.
242
+ expect(performance.now() - started).toBeLessThan(400);
243
+ expect(state.builds).toBe(1);
244
+ });
245
+
246
+ it('builds a new client when the catalog revision moves, and the request answers from the new catalog', async () => {
247
+ const { state, mcp } = await harness();
248
+ const before = (await (await connect(mcp)).listTools()).tools.map((t) => t.name);
249
+ expect(before.some((n) => n.includes('late_arrival'))).toBe(false);
250
+
251
+ // A `.tool` lands on the default branch: the catalog lists it and the
252
+ // revision moves with it.
253
+ state.extraManuals = [lateManual(state.base)];
254
+ state.revision = 'rev-2';
255
+ const after = (await (await connect(mcp)).listTools()).tools.map((t) => t.name);
256
+ expect(after.some((n) => n.includes('late_arrival'))).toBe(true);
257
+ expect(state.builds).toBe(2);
258
+
259
+ // Taken out of the person's reach again: the next request is back without it.
260
+ state.extraManuals = [];
261
+ state.revision = 'rev-3';
262
+ const removed = await connect(mcp);
263
+ expect((await removed.listTools()).tools.some((t) => t.name.includes('late_arrival'))).toBe(false);
264
+ // Called by name anyway, it is refused as an unknown tool — not served from the old surface.
265
+ const res = await removed.callTool({ name: 'late_manual_late_arrival', arguments: {} });
266
+ expect(res.isError).toBe(true);
267
+ expect(JSON.stringify(res.content)).toContain('Unknown tool');
268
+ expect(state.builds).toBe(3);
269
+ });
270
+
271
+ it("rebuilds a person's client after one of their secrets changes, and everyone's after a shared one does", async () => {
272
+ const { state, mcp } = await harness();
273
+ await (await connect(mcp, { userId: 'user-A' })).listTools();
274
+ await (await connect(mcp, { userId: 'user-B', tokenId: 'key-B', bearer: 'bevel_key_B' })).listTools();
275
+ expect(state.builds).toBe(2);
276
+
277
+ mcp.onSecretsChanged('user-A');
278
+ await (await connect(mcp, { userId: 'user-A' })).listTools();
279
+ await (await connect(mcp, { userId: 'user-B', tokenId: 'key-B', bearer: 'bevel_key_B' })).listTools();
280
+ expect(state.builds).toBe(3); // A rebuilt, B still warm
281
+
282
+ mcp.onSecretsChanged(null);
283
+ await (await connect(mcp, { userId: 'user-A' })).listTools();
284
+ await (await connect(mcp, { userId: 'user-B', tokenId: 'key-B', bearer: 'bevel_key_B' })).listTools();
285
+ expect(state.builds).toBe(5);
286
+ });
287
+
288
+ it('drops a client unused for an hour and holds no more than the cap, least recently used first', async () => {
289
+ let now = 1_000_000;
290
+ const { state, mcp } = await harness({ surfaceCache: { maxEntries: 2, now: () => now } });
291
+ const asUser = (u: string) => connect(mcp, { userId: u, tokenId: `key-${u}`, bearer: `bevel_${u}` });
292
+
293
+ await (await asUser('a')).listTools();
294
+ now += 59 * 60 * 1000;
295
+ await (await asUser('a')).listTools();
296
+ expect(state.builds).toBe(1); // used within the hour: still warm
297
+ now += 60 * 60 * 1000;
298
+ await (await asUser('a')).listTools();
299
+ expect(state.builds).toBe(2); // an hour unused: dropped and rebuilt
300
+
301
+ await (await asUser('b')).listTools();
302
+ now += 1;
303
+ await (await asUser('a')).listTools(); // `a` used last; `b` is now the oldest
304
+ await (await asUser('c')).listTools(); // the cap: `b` goes
305
+ expect(state.builds).toBe(4);
306
+ await (await asUser('a')).listTools();
307
+ expect(state.builds).toBe(4);
308
+ await (await asUser('b')).listTools();
309
+ expect(state.builds).toBe(5);
310
+ });
311
+
312
+ it('keeps a client per caller kind: a connection key and the same person\'s session never share one', async () => {
313
+ let minted = 0;
314
+ const { state, mcp } = await harness({ mint: () => `minted-${++minted}` });
315
+ await (await connect(mcp)).listTools();
316
+ await (await connect(mcp, { tokenId: null, bearer: 'eyJ.session' })).listTools();
317
+ expect(state.builds).toBe(2);
318
+ });
319
+
320
+ it('carries each request\'s own bearer: two keys of one person on one warm client are each their own, and a revoked one fails alone', async () => {
321
+ const recorded: AgentEventInput[] = [];
322
+ const { state, mcp } = await harness({ auditRecorder: { record: (e) => recorded.push(e) } });
323
+ const keyOne = await connect(mcp, { tokenId: 'key-1', bearer: 'bevel_key_1' });
324
+ const keyTwo = await connect(mcp, { tokenId: 'key-2', bearer: 'bevel_key_2' });
325
+
326
+ expect(payload(await readFile(keyOne, 'a.md')).bearer).toBe('bevel_key_1');
327
+ expect(payload(await readFile(keyTwo, 'b.md')).bearer).toBe('bevel_key_2');
328
+ expect(state.builds).toBe(1);
329
+ expect(recorded.map((e) => e.principal)).toEqual([
330
+ { kind: 'key', id: 'key-1' },
331
+ { kind: 'key', id: 'key-2' },
332
+ ]);
333
+
334
+ // Key 2 is revoked: its next request is refused at the loopback, key 1's
335
+ // next request keeps working on the same warm client.
336
+ state.refused.add('bevel_key_2');
337
+ const revoked = await readFile(await connect(mcp, { tokenId: 'key-2', bearer: 'bevel_key_2' }), 'c.md');
338
+ expect(revoked.isError).toBe(true);
339
+ expect(payload(await readFile(await connect(mcp, { tokenId: 'key-1', bearer: 'bevel_key_1' }), 'd.md')).bearer).toBe(
340
+ 'bevel_key_1',
341
+ );
342
+ expect(state.builds).toBe(1);
343
+ });
344
+
345
+ it('uses the token minted for THIS request, so a page open past one token\'s lifetime keeps working', async () => {
346
+ let minted = 0;
347
+ const { state, mcp } = await harness({ mint: () => `minted-${++minted}` });
348
+ const session = { tokenId: null, bearer: 'eyJ.session' };
349
+ expect(payload(await readFile(await connect(mcp, session), 'a.md')).bearer).toBe('minted-1');
350
+ // The first token expires (the loopback refuses it); the next request mints its own.
351
+ state.refused.add('minted-1');
352
+ expect(payload(await readFile(await connect(mcp, session), 'b.md')).bearer).toBe('minted-2');
353
+ expect(state.builds).toBe(1);
354
+ });
355
+
356
+ it('answers two calls of one person at the same moment, each with its own result and its own key', async () => {
357
+ const recorded: AgentEventInput[] = [];
358
+ const { state, mcp } = await harness({ auditRecorder: { record: (e) => recorded.push(e) } });
359
+ await (await connect(mcp)).listTools();
360
+ const one = await connect(mcp, { tokenId: 'key-1', bearer: 'bevel_key_1' });
361
+ const two = await connect(mcp, { tokenId: 'key-2', bearer: 'bevel_key_2' });
362
+
363
+ const [a, b, chainA, chainB] = await Promise.all([
364
+ readFile(one, 'one.md', 60),
365
+ readFile(two, 'two.md', 20),
366
+ one.callTool({
367
+ name: 'call_tool_chain',
368
+ arguments: { code: "return KNOWLEDGE_BASE.read_file({ body: { path: 'chain-one.md', delayMs: 40 } });" },
369
+ }),
370
+ two.callTool({
371
+ name: 'call_tool_chain',
372
+ arguments: { code: "return KNOWLEDGE_BASE.read_file({ body: { path: 'chain-two.md', delayMs: 10 } });" },
373
+ }),
374
+ ]);
375
+ expect(payload(a)).toMatchObject({ path: 'one.md', bearer: 'bevel_key_1' });
376
+ expect(payload(b)).toMatchObject({ path: 'two.md', bearer: 'bevel_key_2' });
377
+ expect(JSON.stringify(chainA.content)).toContain('chain-one.md');
378
+ expect(JSON.stringify(chainA.content)).toContain('bevel_key_1');
379
+ expect(JSON.stringify(chainB.content)).toContain('chain-two.md');
380
+ expect(JSON.stringify(chainB.content)).toContain('bevel_key_2');
381
+ expect(state.builds).toBe(1);
382
+
383
+ // Each chain's inner call is audited once, under the key of the request that ran it.
384
+ const inner = recorded.filter((e) => e.name === 'read_file');
385
+ expect(inner).toHaveLength(4);
386
+ const byKey = (id: string) => inner.filter((e) => e.principal.kind === 'key' && e.principal.id === id).length;
387
+ expect(byKey('key-1')).toBe(2);
388
+ expect(byKey('key-2')).toBe(2);
389
+ });
390
+
391
+ it('is invisible across a restart: a new process builds on its first request and answers as before', async () => {
392
+ const { state, mcp, makeService } = await harness();
393
+ const before = (await (await connect(mcp)).listTools()).tools.map((t) => t.name);
394
+ const restarted = makeService();
395
+ const after = (await (await connect(restarted)).listTools()).tools.map((t) => t.name);
396
+ expect(after).toEqual(before);
397
+ expect(payload(await readFile(await connect(restarted), 'a.md'))).toMatchObject({ path: 'a.md' });
398
+ expect(state.builds).toBe(2);
399
+ });
400
+
401
+ it('builds per request when the revision cannot be read, never serving a surface it cannot tell is current', async () => {
402
+ const { state, mcp } = await harness();
403
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
404
+ state.revision = '';
405
+ await (await connect(mcp)).listTools();
406
+ await (await connect(mcp)).listTools();
407
+ expect(state.builds).toBe(2);
408
+ });
409
+
410
+ it('holds one copy of the tool definitions for every client of the same catalog', async () => {
411
+ const { mcp } = await harness({ fillerTools: { count: 5, descriptionBytes: 200 } });
412
+ // Every build hands its client to the shared definitions: that is where the
413
+ // two people's clients are seen, with nothing reached inside the service.
414
+ const share = vi.spyOn(SharedDefinitions.prototype, 'share');
415
+ await (await connect(mcp, { userId: 'user-A' })).listTools();
416
+ await (await connect(mcp, { userId: 'user-B', tokenId: 'key-B' })).listTools();
417
+ expect(share).toHaveBeenCalledTimes(2);
418
+ const [a, b] = share.mock.calls.map(([client]) => client);
419
+ const toolOf = async (client: typeof a) =>
420
+ (await client.config.tool_repository.getTool('KNOWLEDGE_BASE.filler_0'))!;
421
+ expect(a).not.toBe(b);
422
+ // Different clients, different shells — the same definition underneath.
423
+ expect((await toolOf(a)).inputs).toBe((await toolOf(b)).inputs);
424
+ // And no client keeps the bearer of the request that built it.
425
+ const templates = JSON.stringify(await a.config.tool_repository.getManualCallTemplates());
426
+ expect(templates).not.toContain('bevel_key_1');
427
+ });
428
+ });
429
+
430
+ describe('McpService — a broken tool server', () => {
431
+ it('keeps the client for the hour: a manual that fails to load is no reason to rebuild every minute', async () => {
432
+ let now = 1_000_000;
433
+ const { state, mcp } = await harness({ surfaceCache: { now: () => now } });
434
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
435
+ vi.spyOn(console, 'error').mockImplementation(() => {});
436
+ // A `.tool` whose server does not answer: it fails at every build.
437
+ state.extraManuals = [
438
+ { name: 'down_server', call_template_type: 'http', url: 'http://127.0.0.1:1/manual', http_method: 'GET' } as CallTemplate,
439
+ ];
440
+ const first = (await (await connect(mcp)).listTools()).tools.map((t) => t.name);
441
+ expect(first).toContain('read_file');
442
+ expect(first.some((n) => n.startsWith('down_server'))).toBe(false);
443
+ now += 70_000;
444
+ await (await connect(mcp)).listTools();
445
+ now += 50 * 60 * 1000;
446
+ await (await connect(mcp)).listTools();
447
+ expect(state.builds).toBe(1);
448
+
449
+ // What mends it reaches the cache: the admin sets the missing secret.
450
+ mcp.onSecretsChanged(null);
451
+ await (await connect(mcp)).listTools();
452
+ expect(state.builds).toBe(2);
453
+ });
454
+ });
455
+
456
+ describe('McpService — retained heap of warm clients of one catalog', () => {
457
+ it('grows by less than 100 KB per client beyond the first, across 300 people', { timeout: 600_000 }, async () => {
458
+ setFlagsFromString('--expose-gc');
459
+ const gc = runInNewContext('gc') as () => void;
460
+ // A catalog whose definitions alone are ~240 KB: held per client, 300
461
+ // clients would be ~70 MB, and the bound below could not hold. A dozen
462
+ // large tools rather than many small ones: the bytes are what is
463
+ // measured, and each tool costs every build a schema check, which under a
464
+ // loaded suite is the difference between seconds and minutes.
465
+ const { state, mcp } = await harness({ fillerTools: { count: 12, descriptionBytes: 20_000 } });
466
+ const settle = async () => {
467
+ for (let i = 0; i < 3; i++) {
468
+ gc();
469
+ await new Promise((r) => setTimeout(r, 10));
470
+ }
471
+ return process.memoryUsage().heapUsed;
472
+ };
473
+ const warm = async (i: number) => {
474
+ const client = await connect(mcp, { userId: `user-${i}`, tokenId: `key-${i}`, bearer: `bevel_${i}` });
475
+ await client.listTools();
476
+ // The request's server and MCP client go; the cached surface stays.
477
+ for (const c of cleanups.splice(0)) await c().catch(() => {});
478
+ };
479
+ await warm(0);
480
+ const base = await settle();
481
+ for (let i = 1; i < 300; i++) await warm(i);
482
+ const grown = (await settle()) - base;
483
+ expect(grown / 299).toBeLessThan(100 * 1024);
484
+ // What was measured is 300 warm clients: one build each, and every one still held.
485
+ expect(state.builds).toBe(300);
486
+ for (let i = 0; i < 300; i++) await warm(i);
487
+ expect(state.builds).toBe(300);
488
+ });
489
+ });
490
+
491
+ describe("McpService — a page's calls in the Audit log", () => {
492
+ const PAGE = 'Dashboards/Roadmap.html';
493
+
494
+ it('records every call a page makes under the viewer, with the page\'s path as the agent', async () => {
495
+ const recorded: AgentEventInput[] = [];
496
+ let minted = 0;
497
+ const { mcp } = await harness({ auditRecorder: { record: (e) => recorded.push(e) }, mint: () => `minted-${++minted}` });
498
+ const page = await connect(mcp, { userId: 'user-A', tokenId: null, bearer: 'eyJ.session', page: PAGE });
499
+ await readFile(page, 'KnowledgeBase/Foo.md');
500
+ await page.callTool({ name: 'edit_file', arguments: { body: { path: 'KnowledgeBase/Bar.md' } } });
501
+ expect(recorded.map((e) => [e.userId, e.principal, e.name, e.outcome])).toEqual([
502
+ ['user-A', { kind: 'page', path: PAGE }, 'read_file', 'ok'],
503
+ ['user-A', { kind: 'page', path: PAGE }, 'edit_file', 'ok'],
504
+ ]);
505
+
506
+ // The same person's session without a page records nothing, as before.
507
+ await readFile(await connect(mcp, { userId: 'user-A', tokenId: null, bearer: 'eyJ.session' }), 'x.md');
508
+ expect(recorded).toHaveLength(2);
509
+ });
510
+
511
+ it('records a call the viewer refused as refused, named as the page called it', async () => {
512
+ const recorded: AgentEventInput[] = [];
513
+ const { mcp } = await harness({ auditRecorder: { record: (e) => recorded.push(e) }, mint: () => 'minted' });
514
+ await mcp.recordPageRefusal({ userId: 'user-A', bearer: 'eyJ.session' }, PAGE, 'edit_file', { body: { path: 'a.md' } });
515
+ await mcp.recordPageRefusal({ userId: 'user-A', bearer: 'eyJ.session' }, PAGE, 'call_tool_chain', { code: 'return 1' });
516
+ expect(recorded.map((e) => [e.principal, e.kind, e.name, e.outcome])).toEqual([
517
+ [{ kind: 'page', path: PAGE }, 'capability', 'edit_file', 'refused'],
518
+ [{ kind: 'page', path: PAGE }, 'capability', 'call_tool_chain', 'refused'],
519
+ ]);
520
+ });
521
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SharedDefinitions } from '../shared-definitions.js';
3
+
4
+ describe('SharedDefinitions', () => {
5
+ it('answers the first-seen copy of identical content, and holds at most maxEntries', () => {
6
+ const defs = new SharedDefinitions(2);
7
+ const first = [{ name: 'read_file' }];
8
+ expect(defs.intern('tools', first)).toBe(first);
9
+ expect(defs.intern('tools', [{ name: 'read_file' }])).toBe(first);
10
+ defs.intern('tools', [{ name: 'a' }]);
11
+ defs.intern('tools', [{ name: 'b' }]);
12
+ // `read_file` was the least recently used of three: it is no longer held, so a new copy is the first again.
13
+ const again = [{ name: 'read_file' }];
14
+ expect(defs.intern('tools', again)).toBe(again);
15
+ // `b` is still held: a new copy answers the held one.
16
+ const b = [{ name: 'b' }];
17
+ expect(defs.intern('tools', b)).not.toBe(b);
18
+ });
19
+
20
+ it('with maxEntries 0 shares nothing and holds nothing', () => {
21
+ const defs = new SharedDefinitions(0);
22
+ const first = [{ name: 'read_file' }];
23
+ const second = [{ name: 'read_file' }];
24
+ expect(defs.intern('tools', first)).toBe(first);
25
+ expect(defs.intern('tools', second)).toBe(second);
26
+ expect(defs.intern('tools', [{ name: 'read_file' }])).not.toBe(first);
27
+ });
28
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { SURFACE_BUILD_TIMEOUT_MS, SurfaceCache } from '../surface-cache.js';
3
+
4
+ /** A cache on a clock the test moves. */
5
+ function makeCache(opts: { idleTtlMs?: number; maxEntries?: number } = {}) {
6
+ let now = 1_000_000;
7
+ const cache = new SurfaceCache<string>({ ...opts, now: () => now });
8
+ return { cache, advance: (ms: number) => (now += ms) };
9
+ }
10
+
11
+ const HOUR = 60 * 60 * 1000;
12
+
13
+ /** Whether `key` is held: a get that finds it is a hit, and one that does not builds (and so holds it from then on). */
14
+ async function holds(cache: SurfaceCache<string>, key: string): Promise<boolean> {
15
+ return (await cache.get(key, async () => 'rebuilt')).hit;
16
+ }
17
+
18
+ describe('SurfaceCache', () => {
19
+ it('builds once per key and answers every later get from what it built', async () => {
20
+ const { cache } = makeCache();
21
+ const build = vi.fn(async () => 'surface');
22
+ expect(await cache.get('alice|rev-1', build)).toEqual({ value: 'surface', hit: false });
23
+ expect(await cache.get('alice|rev-1', build)).toEqual({ value: 'surface', hit: true });
24
+ expect(build).toHaveBeenCalledTimes(1);
25
+ });
26
+
27
+ it('builds anew for a new key — a new catalog revision is a new surface', async () => {
28
+ const { cache } = makeCache();
29
+ await cache.get('alice|rev-1', async () => 'old');
30
+ expect(await cache.get('alice|rev-2', async () => 'new')).toEqual({ value: 'new', hit: false });
31
+ });
32
+
33
+ it('shares one build between concurrent misses on a key', async () => {
34
+ const { cache } = makeCache();
35
+ let release!: (v: string) => void;
36
+ const build = vi.fn(() => new Promise<string>((r) => (release = r)));
37
+ const first = cache.get('k', build);
38
+ const second = cache.get('k', build);
39
+ release('built');
40
+ expect(await first).toEqual({ value: 'built', hit: false });
41
+ expect(await second).toEqual({ value: 'built', hit: true });
42
+ expect(build).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('lets go of a build that hangs: past the build timeout the next get builds again', async () => {
46
+ const { cache, advance } = makeCache();
47
+ let releaseStuck!: (v: string) => void;
48
+ const stuck = cache.get('k', () => new Promise<string>((r) => (releaseStuck = r)));
49
+ advance(SURFACE_BUILD_TIMEOUT_MS - 1);
50
+ const joined = cache.get('k', async () => 'unused');
51
+ advance(1);
52
+ expect(await cache.get('k', async () => 'fresh')).toEqual({ value: 'fresh', hit: false });
53
+ expect(await cache.get('k', async () => 'unused')).toEqual({ value: 'fresh', hit: true });
54
+ // The hung build still answers whoever was waiting on it, and is not kept.
55
+ releaseStuck('late');
56
+ expect(await stuck).toEqual({ value: 'late', hit: false });
57
+ expect(await joined).toEqual({ value: 'late', hit: true });
58
+ expect(await cache.get('k', async () => 'unused')).toEqual({ value: 'fresh', hit: true });
59
+ });
60
+
61
+ it('remembers no failed build: the next get tries again', async () => {
62
+ const { cache } = makeCache();
63
+ await expect(cache.get('k', async () => Promise.reject(new Error('loopback down')))).rejects.toThrow('loopback down');
64
+ expect(await cache.get('k', async () => 'built')).toEqual({ value: 'built', hit: false });
65
+ });
66
+
67
+ it('drops an entry unused for the idle time, and keeps one that is used', async () => {
68
+ const { cache, advance } = makeCache({ idleTtlMs: HOUR });
69
+ await cache.get('used', async () => 'a');
70
+ await cache.get('idle', async () => 'b');
71
+ advance(HOUR / 2);
72
+ await cache.get('used', async () => 'never');
73
+ advance(HOUR / 2);
74
+ expect(await cache.get('idle', async () => 'rebuilt')).toEqual({ value: 'rebuilt', hit: false });
75
+ expect(await cache.get('used', async () => 'never')).toEqual({ value: 'a', hit: true });
76
+ });
77
+
78
+ it('holds at most maxEntries, dropping the least recently used first', async () => {
79
+ const { cache, advance } = makeCache({ maxEntries: 3 });
80
+ for (const k of ['a', 'b', 'c']) {
81
+ await cache.get(k, async () => k);
82
+ advance(1);
83
+ }
84
+ // `a` is used again, so `b` is now the least recently used.
85
+ await cache.get('a', async () => 'never');
86
+ await cache.get('d', async () => 'd');
87
+ // Checked newest first, so no check's own rebuild pushes out an entry still to be checked.
88
+ expect(await holds(cache, 'd')).toBe(true);
89
+ expect(await holds(cache, 'c')).toBe(true);
90
+ expect(await holds(cache, 'a')).toBe(true);
91
+ expect(await holds(cache, 'b')).toBe(false);
92
+ });
93
+
94
+ it('with maxEntries 0 keeps nothing: every get builds', async () => {
95
+ const { cache } = makeCache({ maxEntries: 0 });
96
+ const build = vi.fn(async () => 'x');
97
+ expect((await cache.get('k', build)).hit).toBe(false);
98
+ expect((await cache.get('k', build)).hit).toBe(false);
99
+ expect(build).toHaveBeenCalledTimes(2);
100
+ });
101
+
102
+ it('evicts by key, and a build in flight at the eviction answers its waiter but is not kept', async () => {
103
+ const { cache } = makeCache();
104
+ await cache.get('alice|r', async () => 'alice');
105
+ await cache.get('bob|r', async () => 'bob');
106
+ cache.evictWhere((k) => k.startsWith('alice|'));
107
+ expect(await holds(cache, 'bob|r')).toBe(true);
108
+ expect(await holds(cache, 'alice|r')).toBe(false);
109
+
110
+ let release!: (v: string) => void;
111
+ const inFlight = cache.get('carol|r', () => new Promise<string>((r) => (release = r)));
112
+ cache.clear();
113
+ release('stale');
114
+ expect((await inFlight).value).toBe('stale');
115
+ expect(await cache.get('carol|r', async () => 'fresh')).toEqual({ value: 'fresh', hit: false });
116
+ expect(await holds(cache, 'bob|r')).toBe(false);
117
+ });
118
+
119
+ it('ends an entry early when its value says so, whatever its use', async () => {
120
+ const { cache, advance } = makeCache();
121
+ await cache.get('k', async () => 'degraded', () => 60_000);
122
+ advance(30_000);
123
+ expect((await cache.get('k', async () => 'never')).hit).toBe(true);
124
+ advance(30_000);
125
+ expect(await cache.get('k', async () => 'whole')).toEqual({ value: 'whole', hit: false });
126
+ });
127
+ });