@bevel-software/platform-core-backend 0.30.0 → 0.32.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 (210) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +16 -3
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +1 -5
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access-model/index.d.ts +14 -0
  19. package/dist/modules/access-model/index.d.ts.map +1 -0
  20. package/dist/modules/access-model/index.js +13 -0
  21. package/dist/modules/access-model/index.js.map +1 -0
  22. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  23. package/dist/modules/audit/agent-audit.service.js +66 -6
  24. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  25. package/dist/modules/audit/audit.contract.d.ts +52 -8
  26. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  27. package/dist/modules/audit/audit.contract.js +37 -1
  28. package/dist/modules/audit/audit.contract.js.map +1 -1
  29. package/dist/modules/audit/audit.routes.d.ts +1 -1
  30. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  31. package/dist/modules/audit/audit.routes.js +15 -13
  32. package/dist/modules/audit/audit.routes.js.map +1 -1
  33. package/dist/modules/audit/request-audit.d.ts +21 -10
  34. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  35. package/dist/modules/audit/request-audit.js +26 -10
  36. package/dist/modules/audit/request-audit.js.map +1 -1
  37. package/dist/modules/auth/account-hooks.d.ts +4 -6
  38. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  39. package/dist/modules/auth/account-hooks.js +7 -17
  40. package/dist/modules/auth/account-hooks.js.map +1 -1
  41. package/dist/modules/auth/auth.service.d.ts +9 -8
  42. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.service.js +19 -18
  44. package/dist/modules/auth/auth.service.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +43 -4
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +31 -7
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/kb-fs/index.d.ts +1 -0
  50. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  51. package/dist/modules/kb-fs/index.js +1 -0
  52. package/dist/modules/kb-fs/index.js.map +1 -1
  53. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  54. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  55. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  56. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  57. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/mutex.js +14 -0
  59. package/dist/modules/kb-fs/mutex.js.map +1 -1
  60. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  61. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  62. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  63. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  64. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  65. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  66. package/dist/modules/mcp/mcp.routes.js +67 -0
  67. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  68. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  69. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  70. package/dist/modules/mcp/mcp.service.js +257 -50
  71. package/dist/modules/mcp/mcp.service.js.map +1 -1
  72. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  73. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  74. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  75. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  76. package/dist/modules/mcp/request-context.d.ts +25 -0
  77. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  78. package/dist/modules/mcp/request-context.js +11 -0
  79. package/dist/modules/mcp/request-context.js.map +1 -0
  80. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  81. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  82. package/dist/modules/mcp/shared-definitions.js +117 -0
  83. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  84. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  85. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  86. package/dist/modules/mcp/surface-cache.js +104 -0
  87. package/dist/modules/mcp/surface-cache.js.map +1 -0
  88. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  89. package/dist/modules/tool-helpers/tool-context.js +33 -12
  90. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  91. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  92. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  93. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  94. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  95. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  96. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  97. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  98. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  99. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  100. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  101. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  102. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  103. package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
  104. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  105. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  106. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  107. package/dist/modules/workflow/change-request-hooks.js +15 -0
  108. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  109. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  110. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  111. package/dist/modules/workflow/git/git.service.js +470 -106
  112. package/dist/modules/workflow/git/git.service.js.map +1 -1
  113. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  114. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  116. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  117. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  118. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  119. package/dist/modules/workflow/read-on-branch.js +58 -0
  120. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  121. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  122. package/dist/modules/workflow/workflow.routes.js +22 -41
  123. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  124. package/dist/modules/workflow/workflow.service.d.ts +124 -24
  125. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  126. package/dist/modules/workflow/workflow.service.js +326 -48
  127. package/dist/modules/workflow/workflow.service.js.map +1 -1
  128. package/dist/modules/workspace/index.d.ts +2 -0
  129. package/dist/modules/workspace/index.d.ts.map +1 -1
  130. package/dist/modules/workspace/index.js +4 -0
  131. package/dist/modules/workspace/index.js.map +1 -1
  132. package/dist/shared/git.contract.d.ts +7 -0
  133. package/dist/shared/git.contract.d.ts.map +1 -1
  134. package/dist/shared/git.contract.js +9 -0
  135. package/dist/shared/git.contract.js.map +1 -1
  136. package/dist/shared/listener-seam.d.ts +34 -0
  137. package/dist/shared/listener-seam.d.ts.map +1 -0
  138. package/dist/shared/listener-seam.js +42 -0
  139. package/dist/shared/listener-seam.js.map +1 -0
  140. package/dist/shared/no-link-below.d.ts +10 -0
  141. package/dist/shared/no-link-below.d.ts.map +1 -0
  142. package/dist/shared/no-link-below.js +30 -0
  143. package/dist/shared/no-link-below.js.map +1 -0
  144. package/migrations/0018_agent_events_page.sql +11 -0
  145. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  146. package/migrations/meta/0018_snapshot.json +2429 -0
  147. package/migrations/meta/0019_snapshot.json +2435 -0
  148. package/migrations/meta/_journal.json +15 -1
  149. package/package.json +3 -3
  150. package/src/core/catalog-revision.ts +3 -3
  151. package/src/core/create-core-server.ts +2 -1
  152. package/src/core/create-core-services.ts +21 -2
  153. package/src/index.ts +7 -0
  154. package/src/modules/access/access-control.service.ts +1 -6
  155. package/src/modules/access-model/index.ts +13 -0
  156. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  157. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  158. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  159. package/src/modules/audit/agent-audit.service.ts +66 -6
  160. package/src/modules/audit/audit.contract.ts +62 -10
  161. package/src/modules/audit/audit.routes.ts +14 -12
  162. package/src/modules/audit/request-audit.ts +28 -13
  163. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  164. package/src/modules/auth/account-hooks.ts +13 -21
  165. package/src/modules/auth/auth.service.ts +19 -18
  166. package/src/modules/database/core-schema.ts +31 -7
  167. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  168. package/src/modules/kb-fs/index.ts +1 -0
  169. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  170. package/src/modules/kb-fs/mutex.ts +16 -0
  171. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  172. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  173. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  174. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  175. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  176. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  177. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  178. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  179. package/src/modules/mcp/mcp.routes.ts +68 -0
  180. package/src/modules/mcp/mcp.service.ts +315 -51
  181. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  182. package/src/modules/mcp/request-context.ts +34 -0
  183. package/src/modules/mcp/shared-definitions.ts +121 -0
  184. package/src/modules/mcp/surface-cache.ts +154 -0
  185. package/src/modules/tool-helpers/tool-context.ts +37 -18
  186. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  187. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  188. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  189. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  190. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  191. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  192. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  193. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
  194. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
  195. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  196. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  197. package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
  198. package/src/modules/workflow/change-request-hooks.ts +96 -0
  199. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  200. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  201. package/src/modules/workflow/git/git.service.ts +537 -106
  202. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  203. package/src/modules/workflow/read-on-branch.ts +79 -0
  204. package/src/modules/workflow/workflow.routes.ts +21 -39
  205. package/src/modules/workflow/workflow.service.ts +390 -55
  206. package/src/modules/workspace/index.ts +11 -0
  207. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  208. package/src/shared/git.contract.ts +10 -0
  209. package/src/shared/listener-seam.ts +52 -0
  210. package/src/shared/no-link-below.ts +29 -0
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import type { McpCaller } from '../mcp.service.js';
5
+ import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
6
+
7
+ /**
8
+ * The page a call comes from: an HTML page of the knowledge base whose script
9
+ * calls the viewer's tools through the app. The app names it in
10
+ * `X-Bevel-Page`, and only the app — a session — may: a key, an OAuth token or
11
+ * an internal token belongs to an agent with an Audit log row of its own.
12
+ */
13
+
14
+ afterEach(async () => {
15
+ await closeMountedRoutes();
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ function makeMcpService() {
20
+ const callers: McpCaller[] = [];
21
+ return {
22
+ callers,
23
+ createRequestServer: vi.fn(async (caller: McpCaller) => {
24
+ callers.push(caller);
25
+ const server = new Server({ name: 'stub', version: '0' }, { capabilities: { tools: {} } });
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
27
+ return server;
28
+ }),
29
+ recordPageRefusal: vi.fn(async () => {}),
30
+ };
31
+ }
32
+
33
+ const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
34
+ const PAGE = 'Dashboards/Road map — Q4.html';
35
+
36
+ function post(baseUrl: string, path: string, body: unknown, headers: Record<string, string>) {
37
+ return fetch(`${baseUrl}/api${path}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
40
+ body: JSON.stringify(body),
41
+ });
42
+ }
43
+
44
+ describe('POST /mcp with X-Bevel-Page', () => {
45
+ it("hands a session's page to the service, decoded", async () => {
46
+ const mcpService = makeMcpService();
47
+ const baseUrl = await mountMcpRoutes({ mcpService });
48
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent(PAGE) });
49
+ expect(res.status).toBe(200);
50
+ expect(mcpService.callers[0]).toMatchObject({ userId: 'user-A', page: PAGE });
51
+ });
52
+
53
+ it.each(['key', 'oauth', 'internal'])('refuses the header from a %s caller, building nothing', async (kind) => {
54
+ const mcpService = makeMcpService();
55
+ const baseUrl = await mountMcpRoutes({ mcpService });
56
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': kind, 'x-bevel-page': encodeURIComponent(PAGE) });
57
+ expect(res.status).toBe(400);
58
+ expect(((await res.json()) as { error: { message: string } }).error.message).toContain('X-Bevel-Page');
59
+ expect(mcpService.createRequestServer).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('refuses a page that is not a path', async () => {
63
+ const mcpService = makeMcpService();
64
+ const baseUrl = await mountMcpRoutes({ mcpService });
65
+ const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent('a\nb') });
66
+ expect(res.status).toBe(400);
67
+ });
68
+
69
+ it('names no page when the header is absent, as before', async () => {
70
+ const mcpService = makeMcpService();
71
+ const baseUrl = await mountMcpRoutes({ mcpService });
72
+ await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session' });
73
+ expect(mcpService.callers[0]).not.toHaveProperty('page');
74
+ });
75
+ });
76
+
77
+ describe('POST /mcp/page-refusals', () => {
78
+ it("records a session's refused call under its page", async () => {
79
+ const mcpService = makeMcpService();
80
+ const baseUrl = await mountMcpRoutes({ mcpService });
81
+ const res = await post(
82
+ baseUrl,
83
+ '/mcp/page-refusals',
84
+ { page: PAGE, tool: 'edit_file', args: { body: { path: 'a.md' } } },
85
+ { 'x-test-kind': 'session', Authorization: 'Bearer eyJ.session' },
86
+ );
87
+ expect(res.status).toBe(204);
88
+ expect(mcpService.recordPageRefusal).toHaveBeenCalledWith(
89
+ { userId: 'user-A', bearer: 'eyJ.session' },
90
+ PAGE,
91
+ 'edit_file',
92
+ { body: { path: 'a.md' } },
93
+ );
94
+ });
95
+
96
+ it('is the app\'s alone: an agent credential is refused', async () => {
97
+ const mcpService = makeMcpService();
98
+ const baseUrl = await mountMcpRoutes({ mcpService });
99
+ const res = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool: 'edit_file' }, { 'x-test-kind': 'key' });
100
+ expect(res.status).toBe(403);
101
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('wants a page and a tool', async () => {
105
+ const mcpService = makeMcpService();
106
+ const baseUrl = await mountMcpRoutes({ mcpService });
107
+ const res = await post(baseUrl, '/mcp/page-refusals', { tool: 'edit_file' }, { 'x-test-kind': 'session' });
108
+ expect(res.status).toBe(400);
109
+ for (const tool of [undefined, '']) {
110
+ const noTool = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool }, { 'x-test-kind': 'session' });
111
+ expect(noTool.status).toBe(400);
112
+ }
113
+ expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
114
+ });
115
+ });
@@ -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
+ });