@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
@@ -12,12 +12,15 @@ import { bevelSecretsLoaderConfig } from '../secrets-vault/index.js';
12
12
  import { scopesCovered, } from '../secrets-vault/secrets-vault.contract.js';
13
13
  import { EXTERNAL_KB_MANUAL_NAME } from '../tool-manuals/tool-manuals.contract.js';
14
14
  import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
15
- import { RequestAudit } from '../audit/request-audit.js';
15
+ import { RequestAudit } from '../audit/index.js';
16
16
  import { ManualFailureMemo } from './manual-failure-memo.js';
17
17
  import { DownstreamPool, POOL_KEY_SEPARATOR } from './downstream-pool.js';
18
18
  import { SurfaceLogThrottle } from './surface-log-throttle.js';
19
19
  import { ToolSchemaGuard } from './tool-schema-guard.js';
20
20
  import { DownstreamRefreshGuard, isDownstreamTokenRejection } from './downstream-token-refresh.js';
21
+ import { SurfaceCache } from './surface-cache.js';
22
+ import { SharedDefinitions } from './shared-definitions.js';
23
+ import { currentRequest, runInRequest } from './request-context.js';
21
24
  import { printable } from '../../shared/printable.js';
22
25
  import { composeAgentInstructions, prefixToolDescription, PREFIXED_TOOLS, } from '../agent-instructions/index.js';
23
26
  import { guideFirstDescription } from '../tool-registry/guide-first.js';
@@ -40,26 +43,54 @@ const LOOPBACK_TIMEOUT_MS = 15_000;
40
43
  */
41
44
  export const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
42
45
  const callTemplateSerializer = new CallTemplateSerializer();
46
+ /**
47
+ * How long a surface missing a manual that failed is kept (see
48
+ * {@link RequestSurface.degraded}): the refresh policy's window, so the
49
+ * manual is retried as soon as a refresh may have settled.
50
+ */
51
+ const DEGRADED_SURFACE_MAX_AGE_MS = 60_000;
52
+ /**
53
+ * What the platform-hosted manuals' `${CONNECTION_KEY}` is seeded with when a
54
+ * cached client is built. Never sent anywhere: the variable is replaced by a
55
+ * getter that reads the bearer of the request a call belongs to (see
56
+ * {@link bindRequestBearer}).
57
+ */
58
+ const REQUEST_BEARER_PLACEHOLDER = 'request-bearer';
43
59
  /** The {@link DownstreamRoute.afterFailure} answer that means "refreshed — retry once". */
44
60
  const RETRY_WITH_REFRESHED_TOKEN = Symbol('retry-with-refreshed-token');
45
61
  /**
46
62
  * The MCP server is a GENERIC, STATELESS proxy over the UTCP tool surface. It
47
63
  * owns no tool logic and no session: every HTTP request gets its own MCP
48
64
  * `Server`, built from that request's bearer. When the request needs tools it
49
- * stands up a `UtcpClient` pointed at the backend's own `GET /api/agent/all-tools`
50
- * catalog (over loopback, authenticated as the caller, so ACL filtering lives
51
- * ONCE behind the REST surface), discovers every tool, and re-exposes each one
52
- * to the MCP client. A tool call is dispatched straight back through the same
65
+ * uses the caller's `UtcpClient` — pointed at the backend's own
66
+ * `GET /api/agent/all-tools` catalog (over loopback, authenticated as the
67
+ * caller, so ACL filtering lives ONCE behind the REST surface), every tool
68
+ * discovered — and re-exposes each one to the MCP client. A tool call is dispatched straight back through the same
53
69
  * REST endpoint the tool's UTCP `tool_call_template` names — so the agent logic,
54
70
  * thread continuity, and per-key token metering all live ONCE behind that REST
55
71
  * endpoint (`AgentAskService` for `ask`), never duplicated here.
56
72
  *
57
- * Because nothing is held between requests, a platform restart between two
58
- * requests of one client conversation is undetectable by the client.
73
+ * The client is built once per person, caller kind, key scope and catalog
74
+ * revision, and reused until one of those changes (see {@link SurfaceCache}):
75
+ * the revision is the fingerprint `hexis-mcp` already polls, so a `.tool`
76
+ * added, edited or put out of the caller's reach is a new revision and a new
77
+ * build, while a note saved anywhere is not. A secret changing evicts the
78
+ * person's clients (a shared one, everyone's). Building cost a full catalog
79
+ * fetch and discovery per request, which on a slow knowledge base was the
80
+ * difference between an answer and a client's discovery timeout.
59
81
  *
60
- * There is NO catalog cache at this layer, deliberately: the catalog is
61
- * re-derived per request, and if that ever proves too slow the fix belongs in
62
- * the source path (all-tools/ACL), which serves every other caller too.
82
+ * A cached client carries no request's bearer. Each request brings its own,
83
+ * through the request context (see `request-context.ts`): the
84
+ * platform-hosted manuals read it at call time, so a connection key's metering
85
+ * and audit stay its own, a revoked key fails on its next request while the
86
+ * person's client stays warm for their other key, and a token minted for one
87
+ * request never has to outlive it. The cache holds references and nothing the
88
+ * process could not rebuild, so a restart between two requests is still
89
+ * undetectable by the client: the first request after it rebuilds.
90
+ *
91
+ * Tool definitions are held once for every client that discovered the same
92
+ * ones (see {@link SharedDefinitions}), so hundreds of warm clients of one
93
+ * catalog cost one catalog's worth of schemas and descriptions.
63
94
  *
64
95
  * The one exception to "nothing between requests" is proxied third-party MCP
65
96
  * servers (`mcp` manuals): those are session-ful by nature, so their
@@ -118,6 +149,10 @@ export class McpService {
118
149
  toolSchemas = new ToolSchemaGuard();
119
150
  // The downstream connection pool for `mcp` manuals — see the class doc.
120
151
  downstream;
152
+ // Built surfaces, per caller and catalog revision — see the class doc.
153
+ surfaces;
154
+ // Tool definitions shared by every surface that discovered the same ones.
155
+ definitions = new SharedDefinitions();
121
156
  constructor(opts,
122
157
  // The vault + manual catalog, used to check a caller's per-user credentials
123
158
  // before dispatching a tool. Optional so existing constructions/tests that
@@ -164,25 +199,31 @@ export class McpService {
164
199
  });
165
200
  // Paced on the pool's clock, so a test that moves one moves both.
166
201
  this.tokenRefreshes = new DownstreamRefreshGuard(undefined, opts.downstreamPool?.now);
202
+ this.surfaces = new SurfaceCache(opts.surfaceCache);
167
203
  }
168
204
  /**
169
205
  * A secret changed — wired to the secrets vault's mutation listener.
170
206
  * `null` = a shared secret changed (affects everyone).
171
207
  *
172
- * Two things were built from the old value: remembered manual failures (so a
173
- * just-repaired credential is retried on the very next request) and pooled
174
- * downstream connections (dialed and discovered with the old credential).
175
- * Both are dropped; the next request rebuilds whatever it needs.
208
+ * Three things were built from the old value: remembered manual failures (so
209
+ * a just-repaired credential is retried on the very next request), pooled
210
+ * downstream connections (dialed and discovered with the old credential),
211
+ * and cached surfaces (whose tools, and whose list of manuals waiting on a
212
+ * sign-in, were decided with it). All are dropped; the next request
213
+ * rebuilds whatever it needs.
176
214
  */
177
215
  onSecretsChanged(userId) {
178
216
  this.forgetStaleRefreshWindows(userId);
179
217
  if (userId === null) {
180
218
  this.manualFailures.clearAll();
181
219
  this.downstream.closeAll();
220
+ this.surfaces.clear();
182
221
  return;
183
222
  }
184
223
  this.manualFailures.clearUser(userId);
185
- this.downstream.evictWhere((key) => key.startsWith(`${userId}${POOL_KEY_SEPARATOR}`));
224
+ const mine = (key) => key.startsWith(`${userId}${POOL_KEY_SEPARATOR}`);
225
+ this.downstream.evictWhere(mine);
226
+ this.surfaces.evictWhere(mine);
186
227
  }
187
228
  /**
188
229
  * A credential changed, so a refresh window that FAILED to produce one no
@@ -208,12 +249,14 @@ export class McpService {
208
249
  * Cheap by construction: nothing is fetched up front except what the
209
250
  * request's own messages need. The agent instructions are read only when the
210
251
  * request carries an `initialize` (the only place they ride); the tool
211
- * catalog is built on the first handler that needs it and shared by every
212
- * message of the request (a JSON-RPC batch), never beyond it.
252
+ * surface is looked up on the first handler that needs it — the caller's
253
+ * cached one, or a fresh build — and shared by every message of the request
254
+ * (a JSON-RPC batch).
213
255
  */
214
256
  async createRequestServer(caller, messages) {
215
257
  const { userId, tokenId, bearer } = caller;
216
258
  const connectionId = caller.connectionId ?? null;
259
+ const page = caller.page ?? null;
217
260
  // The loopback surface (`/api/agent/*`) accepts connection keys and
218
261
  // internal tokens only. A connection-key request passes the caller's own
219
262
  // key through (per-key metering rides on it); an OAuth/JWT request's
@@ -228,11 +271,17 @@ export class McpService {
228
271
  let instructions;
229
272
  const agentInstructions = () => (instructions ??= this.composeAgentInstructions());
230
273
  // The Audit log's view of this request — only when the request can be
231
- // attributed to a key or an agent connection. The catalog names it
232
- // classifies pooled tools by are the surface's, which is built lazily
233
- // below, so it reads them through a holder the surface fills in.
274
+ // attributed to a key, an agent connection or a page. The catalog names
275
+ // it classifies pooled tools by are the surface's, which is looked up
276
+ // lazily below, so it reads them through a holder the surface fills in.
234
277
  const catalogNamesHolder = { current: new Map() };
235
- const principal = tokenId != null ? { kind: 'key', id: tokenId } : connectionId ? { kind: 'agent', id: connectionId } : null;
278
+ const principal = tokenId != null
279
+ ? { kind: 'key', id: tokenId }
280
+ : connectionId
281
+ ? { kind: 'agent', id: connectionId }
282
+ : page
283
+ ? { kind: 'page', path: page }
284
+ : null;
236
285
  const audit = this.auditRecorder && principal
237
286
  ? new RequestAudit(this.auditRecorder, userId, principal, {
238
287
  kbManualName: EXTERNAL_KB_MANUAL_NAME,
@@ -240,11 +289,15 @@ export class McpService {
240
289
  loadSkills: () => this.fetchSkillList(loopbackBearer).then((skills) => skills.flatMap((s) => (s.path ? [{ name: s.name, path: s.path }] : []))),
241
290
  })
242
291
  : null;
292
+ // What this request brings to a client it shares with the person's other
293
+ // requests: its own loopback bearer and its own audit. Every handler that
294
+ // reaches the client runs inside it.
295
+ const ctx = { loopbackBearer, audit };
296
+ const inRequest = (fn) => runInRequest(ctx, fn);
243
297
  let surface;
244
- const requestSurface = () => (surface ??= this.buildSurface(userId, tokenId, loopbackBearer).then((built) => {
245
- catalogNamesHolder.current = built.catalogNames;
246
- audit?.instrumentChainCalls(built.client);
247
- return built;
298
+ const requestSurface = () => (surface ??= inRequest(() => this.surfaceFor(caller, loopbackBearer)).then((found) => {
299
+ catalogNamesHolder.current = found.catalogNames;
300
+ return found;
248
301
  }));
249
302
  const initializing = (Array.isArray(messages) ? messages : [messages]).some((m) => isInitializeRequest(m));
250
303
  // The deployment's MCP Apps, read once per request. Declaring the
@@ -267,7 +320,7 @@ export class McpService {
267
320
  // place the text in the model's system prompt without the model acting.
268
321
  ...(initializing ? { instructions: (await agentInstructions()).instructions } : {}),
269
322
  });
270
- server.setRequestHandler(ListToolsRequestSchema, async () => {
323
+ server.setRequestHandler(ListToolsRequestSchema, async () => inRequest(async () => {
271
324
  const [{ tools, catalogNames }, { toolPrefix }] = await Promise.all([requestSurface(), agentInstructions()]);
272
325
  // A discovered tool whose name collides with a meta-tool would be
273
326
  // listed but never callable (the dispatcher routes the name to the
@@ -360,7 +413,7 @@ export class McpService {
360
413
  // Code-mode meta-tools first, then every validated direct tool.
361
414
  tools: [...metaTools, ...direct],
362
415
  };
363
- });
416
+ }));
364
417
  // The MCP App views. Registered only when this deployment serves one, so
365
418
  // the handlers and the declared capability agree.
366
419
  if (servesApps && apps) {
@@ -376,8 +429,12 @@ export class McpService {
376
429
  return toReadResourceResult(resource);
377
430
  });
378
431
  }
379
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
380
- const { client, tools, unavailable, catalogNames, hidden: hiddenTools } = await requestSurface();
432
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => inRequest(async () => {
433
+ const { client: shared, tools, unavailable, catalogNames, hidden: hiddenTools } = await requestSurface();
434
+ // The person's client, as this request uses it: every call it makes —
435
+ // a chain's too, which comes back from the isolate outside any async
436
+ // context — carries this request's bearer and audit.
437
+ const client = requestView(shared, ctx);
381
438
  const toolName = request.params.name;
382
439
  const args = request.params.arguments ?? {};
383
440
  if (META_TOOL_NAMES.has(toolName)) {
@@ -462,7 +519,7 @@ export class McpService {
462
519
  const structured = apps !== null && Object.hasOwn(apps.tools, proxied.mcpName);
463
520
  const run = () => this.dispatch(client, proxied, request, extra, structured);
464
521
  return audit ? audit.call(proxied.utcpName, args, run, isErrorResult) : run();
465
- });
522
+ }));
466
523
  // Prompts = skills. Each skill becomes a user-callable prompt (slash command
467
524
  // in MCP clients). Backed by the same skill endpoints the tools use, over
468
525
  // loopback with the caller's key — so the default-branch catalog, access
@@ -493,22 +550,116 @@ export class McpService {
493
550
  return server;
494
551
  }
495
552
  /**
496
- * The request's tool surface: catalog over loopback, a fresh UTCP client,
497
- * every manual registered (pooled for `mcp` ones), tools flattened.
553
+ * Record that the person viewing `page` refused it a call: the page asked to
554
+ * write, the app asked them, and they answered Deny. Nothing ran, so the
555
+ * call never reached this server as a `tools/call`; the app reports it here
556
+ * so the Audit log shows the page's attempt beside its allowed calls.
498
557
  *
499
- * Logs one line with the time each half took — the per-request overhead
500
- * the stateless design trades for restart-invisibility, kept visible so it
501
- * is measured rather than assumed.
558
+ * The tool is named the way the page named it (its MCP name) and recorded
559
+ * the way that call would have been: through the person's surface, which
560
+ * the page's earlier calls have normally left warm.
502
561
  */
503
- async buildSurface(userId, tokenId, loopbackBearer) {
562
+ async recordPageRefusal(caller, page, toolName, args) {
563
+ if (!this.auditRecorder)
564
+ return;
565
+ const { userId } = caller;
566
+ const loopbackBearer = this.internalTokens
567
+ ? this.internalTokens.mint({ userId, externalProxy: true }, MCP_LOOPBACK_TOKEN_TTL_MS)
568
+ : caller.bearer;
569
+ let catalogNames = new Map();
570
+ const audit = new RequestAudit(this.auditRecorder, userId, { kind: 'page', path: page }, {
571
+ kbManualName: EXTERNAL_KB_MANUAL_NAME,
572
+ catalogNames: () => catalogNames,
573
+ loadSkills: () => this.fetchSkillList(loopbackBearer).then((skills) => skills.flatMap((s) => (s.path ? [{ name: s.name, path: s.path }] : []))),
574
+ });
575
+ const ctx = { loopbackBearer, audit };
576
+ await runInRequest(ctx, async () => {
577
+ let utcpName = toolName;
578
+ if (!META_TOOL_NAMES.has(toolName)) {
579
+ const surface = await this.surfaceFor({ ...caller, tokenId: null }, loopbackBearer);
580
+ catalogNames = surface.catalogNames;
581
+ utcpName =
582
+ surface.tools.find((t) => t.mcpName === toolName)?.utcpName ?? surface.hidden.get(toolName)?.utcpName ?? toolName;
583
+ }
584
+ await audit.denied(utcpName, args, 'refused');
585
+ });
586
+ }
587
+ /**
588
+ * The caller's tool surface: the one cached for their catalog revision, or
589
+ * a fresh build that is then cached.
590
+ *
591
+ * The key is the person, the kind of caller (a connection key, or a person
592
+ * through OAuth or their session — the latter two reach the same loopback
593
+ * identity), the credential's scope, and the catalog revision. The revision
594
+ * is asked for on every request, with the request's own bearer, so it is
595
+ * the caller's CURRENT catalog that picks the surface: a manual released,
596
+ * edited or put out of their reach moves it, and the request builds anew.
597
+ *
598
+ * When the revision cannot be read the request builds a surface of its own
599
+ * and keeps it to itself — the per-request behaviour this replaced, never a
600
+ * surface it cannot tell is current.
601
+ */
602
+ async surfaceFor(caller, loopbackBearer) {
603
+ const { userId, tokenId } = caller;
604
+ const revision = await this.fetchCatalogRevision(loopbackBearer);
605
+ if (revision === null)
606
+ return this.buildSurface(userId, tokenId, loopbackBearer, 'uncached');
607
+ const key = [userId, tokenId != null ? 'key' : 'person', caller.scope ?? 'write', revision].join(POOL_KEY_SEPARATOR);
608
+ const { value } = await this.surfaces.get(key, () => this.buildSurface(userId, tokenId, loopbackBearer, 'miss'), (built) => (built.degraded ? DEGRADED_SURFACE_MAX_AGE_MS : undefined));
609
+ return value;
610
+ }
611
+ /**
612
+ * The caller's catalog revision (`GET /api/agent/catalog-revision`, the one
613
+ * `hexis-mcp` polls), read with their bearer; null when it cannot be read.
614
+ * A deployment or a test that does not serve the route answers 404, which
615
+ * is not worth a line in the log; anything else is.
616
+ */
617
+ async fetchCatalogRevision(bearer) {
618
+ try {
619
+ const res = await fetch(`${this.opts.loopbackBaseUrl}/api/agent/catalog-revision`, {
620
+ headers: { Authorization: `Bearer ${bearer}` },
621
+ signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
622
+ });
623
+ if (!res.ok) {
624
+ if (res.status !== 404)
625
+ log.warn(`catalog-revision loopback failed: HTTP ${res.status}; building this request's surface uncached`);
626
+ return null;
627
+ }
628
+ const body = (await res.json());
629
+ return typeof body.revision === 'string' && body.revision ? body.revision : null;
630
+ }
631
+ catch (err) {
632
+ log.warn("catalog-revision loopback threw; building this request's surface uncached:", { err });
633
+ return null;
634
+ }
635
+ }
636
+ /**
637
+ * Build a tool surface: catalog over loopback, a UTCP client, every manual
638
+ * registered (pooled for `mcp` ones), tools flattened, definitions shared.
639
+ *
640
+ * Runs inside the request that asked for it, whose bearer the knowledge
641
+ * base's discovery reads; the client it builds keeps none of it.
642
+ *
643
+ * Logs one line with the time each half took and whether it was a cache
644
+ * miss, so a build is measured rather than assumed, and a log that shows
645
+ * one line for two requests shows the second was served warm.
646
+ */
647
+ async buildSurface(userId, tokenId, loopbackBearer, cache) {
504
648
  const started = performance.now();
505
649
  // The list of manuals this caller gets: the KB manual + each `.tool` they
506
650
  // can read. Fetched over loopback with the caller's key, so ACL filtering
507
651
  // lives ONCE behind the REST surface.
508
652
  const manuals = await this.fetchManualTemplates(loopbackBearer);
509
653
  const catalogMs = performance.now() - started;
510
- const client = await this.buildClient(loopbackBearer, userId, manuals);
511
- const { tools, unavailable, catalogNames, hidden } = await this.discoverTools(client, manuals, userId);
654
+ const client = await this.buildClient(userId, manuals);
655
+ const discovered = await this.discoverTools(client, manuals, userId);
656
+ // After discovery, which renames each template in place to the name it
657
+ // registered under: that is the name the repository keys it by.
658
+ const rawTemplates = new Map(manuals.map((m) => [utcpManualName(m), m]));
659
+ await this.definitions.share(client, rawTemplates);
660
+ // Chain calls are audited per request, by whichever request makes them.
661
+ RequestAudit.instrumentChainCalls(client, () => currentRequest()?.audit);
662
+ const tools = this.definitions.listing(discovered.tools);
512
663
  const totalMs = performance.now() - started;
513
664
  // Per user: on a shape change or once per interval, never per request —
514
665
  // see SurfaceLogThrottle for why both halves matter.
@@ -517,12 +668,12 @@ export class McpService {
517
668
  log.info(
518
669
  // Both ids come from the request (an identity provider's subject, a
519
670
  // connection key's id), so neither is interpolated raw — see printable.
520
- `request surface: user=${printable(userId)} tokenId=${tokenId ? printable(tokenId) : 'none'} — ` +
671
+ `request surface: user=${printable(userId)} tokenId=${tokenId ? printable(tokenId) : 'none'} cache=${cache} — ` +
521
672
  `${tools.length} tool(s) across ${manuals.length} manual(s) in ${totalMs.toFixed(0)}ms ` +
522
673
  `(catalog ${catalogMs.toFixed(0)}ms, registration ${(totalMs - catalogMs).toFixed(0)}ms)` +
523
674
  (decision.suppressed > 0 ? ` [+${decision.suppressed} identical rebuild(s) since last line]` : ''));
524
675
  }
525
- return { client, tools, unavailable, catalogNames, hidden };
676
+ return { ...discovered, client, tools };
526
677
  }
527
678
  /**
528
679
  * The instructions and tool prefix. A reader failure (a disk fault; ENOENT
@@ -635,7 +786,7 @@ export class McpService {
635
786
  });
636
787
  }
637
788
  /**
638
- * One `CodeModeUtcpClient` per request. Reserved `API_URL`/`CONNECTION_KEY` are
789
+ * One `CodeModeUtcpClient` per surface. Reserved `API_URL`/`CONNECTION_KEY` are
639
790
  * seeded (namespaced) ONLY for Bevel-hosted manuals — the KB manual and inline
640
791
  * `.tool` sub-manuals, whose discovery template targets the loopback (`${API_URL}`).
641
792
  * Third-party http/mcp `.tool`s point at arbitrary user URLs, so we must NOT seed
@@ -643,13 +794,20 @@ export class McpService {
643
794
  * would otherwise exfiltrate the caller's token to its own endpoint. Tool
644
795
  * `${SECRET}` refs resolve lazily via the per-user `bevel-secrets` loader.
645
796
  *
646
- * Not `close()`d: a request's client holds no connection of its own (its
647
- * `mcp` manuals route to the pool, so its own `mcp` protocol instance never
648
- * opens a session), and dropping the reference is the whole teardown.
797
+ * `CONNECTION_KEY` is seeded as a placeholder and bound to the request
798
+ * context (see {@link bindRequestBearer}): the client is shared by the
799
+ * person's requests, and each call carries the bearer of the request it
800
+ * belongs to.
801
+ *
802
+ * Not `close()`d: a client holds no connection of its own (its `mcp`
803
+ * manuals route to the pool, so its own `mcp` protocol instance never opens
804
+ * a session), and dropping the reference is the whole teardown.
649
805
  */
650
- async buildClient(bearer, userId, manuals) {
651
- const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, bearer);
652
- return CodeModeUtcpClient.create(process.cwd(), utcpClientConfig(userId, variables, this.opts.secretsScope));
806
+ async buildClient(userId, manuals) {
807
+ const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, REQUEST_BEARER_PLACEHOLDER);
808
+ const client = await CodeModeUtcpClient.create(process.cwd(), utcpClientConfig(userId, variables, this.opts.secretsScope));
809
+ bindRequestBearer(client);
810
+ return client;
653
811
  }
654
812
  /**
655
813
  * Register EVERY manual for this request, then flatten each discovered tool
@@ -663,14 +821,16 @@ export class McpService {
663
821
  * pool (see {@link attachDownstream}).
664
822
  *
665
823
  * Failures are memoized per (user, manual definition) for a few minutes (see
666
- * {@link ManualFailureMemo}): the catalog is rebuilt on every request, and a
667
- * manual with a broken credential (expired OAuth, revoked key) would
824
+ * {@link ManualFailureMemo}): a surface is rebuilt when its catalog
825
+ * revision or the caller's secrets change (see {@link RequestSurface.degraded}), and a manual with a broken credential (expired OAuth, revoked key) would
668
826
  * otherwise re-dial its provider on every single one. The definition's
669
827
  * fingerprint is part of the key, so a manual edited after a failure (a
670
828
  * corrected `mcp.json` URL) is tried on the very next request.
671
829
  */
672
830
  async discoverTools(client, manuals, userId) {
673
831
  const routes = new Map();
832
+ // A manual left off this surface for a failure (see RequestSurface.degraded).
833
+ let degraded = false;
674
834
  const unavailable = [];
675
835
  // Taken BEFORE a single manual is registered, so it ranks this load by the
676
836
  // freshness of what it is about to read — see ToolSchemaGuard.beginLoad.
@@ -761,6 +921,8 @@ export class McpService {
761
921
  this.manualFailures.recordFailure(userId, memoKey, result.error, generation);
762
922
  if (result.signInMissing)
763
923
  unavailable.push({ utcpName: rewritten });
924
+ else if (result.retryable)
925
+ degraded = true;
764
926
  log.warn(`skipping manual "${name}": ${result.error}`);
765
927
  }
766
928
  else if (!isKb) {
@@ -780,7 +942,7 @@ export class McpService {
780
942
  const utcpTools = await client.getTools();
781
943
  const flattened = utcpTools.map((tool) => flattenManualTool(tool, EXTERNAL_KB_MANUAL_NAME));
782
944
  const { tools, hidden } = await this.hideInvalidSchemas(client, flattened, manualCatalogNames, userId, loadId);
783
- return { tools, unavailable, catalogNames, hidden };
945
+ return { tools, unavailable, catalogNames, hidden, degraded };
784
946
  }
785
947
  /**
786
948
  * Check the input schema of every tool the servers just advertised, and take
@@ -1290,6 +1452,51 @@ function routeToDownstream(client, routes) {
1290
1452
  yield* once();
1291
1453
  };
1292
1454
  }
1455
+ /**
1456
+ * The person's shared client as ONE request uses it: a view whose `callTool`
1457
+ * runs inside that request's context, and which is otherwise the client
1458
+ * itself (the prototype), so its repository, protocols and routing are the
1459
+ * shared ones.
1460
+ *
1461
+ * `callTool` is the entry `call_tool_chain` bridges every in-isolate tool
1462
+ * call to, and those calls come back from isolated-vm's callback queue with
1463
+ * no async context of their own: without the view they would run as no
1464
+ * request at all, with no bearer and no audit. The direct path
1465
+ * (`callToolStreaming`) runs in the request's handler and needs nothing
1466
+ * more.
1467
+ */
1468
+ function requestView(client, ctx) {
1469
+ const view = Object.create(client);
1470
+ view.callTool = (toolName, toolArgs) => runInRequest(ctx, () => client.callTool(toolName, toolArgs));
1471
+ return view;
1472
+ }
1473
+ /**
1474
+ * Make every platform-hosted manual's `${CONNECTION_KEY}` the bearer of the
1475
+ * request a call belongs to.
1476
+ *
1477
+ * The UTCP client substitutes a tool's `${VAR}`s each time the tool is
1478
+ * called, from `config.variables` first — so a getter there is read at call
1479
+ * time, never at build time. A call outside any request has no bearer to
1480
+ * send, and is refused here rather than sent with the placeholder.
1481
+ */
1482
+ function bindRequestBearer(client) {
1483
+ const variables = client.config.variables ?? {};
1484
+ for (const [key, value] of Object.entries(variables)) {
1485
+ if (!key.endsWith('_CONNECTION_KEY') || value !== REQUEST_BEARER_PLACEHOLDER)
1486
+ continue;
1487
+ Object.defineProperty(variables, key, {
1488
+ enumerable: true,
1489
+ configurable: false,
1490
+ get: () => {
1491
+ const request = currentRequest();
1492
+ if (!request)
1493
+ throw new Error('A platform tool was called outside an MCP request, so there is no bearer to call it with.');
1494
+ return request.loopbackBearer;
1495
+ },
1496
+ });
1497
+ }
1498
+ client.config.variables = variables;
1499
+ }
1293
1500
  /**
1294
1501
  * The pieces of the proxy that are shared with the local MCP server now live in
1295
1502
  * `@bevel-software/platform-mcp-core`. Re-exported here because this module is