@bevel-software/platform-core-backend 0.30.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 (156) 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.map +1 -1
  8. package/dist/core/create-core-services.js +4 -2
  9. package/dist/core/create-core-services.js.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  11. package/dist/modules/access/access-control.service.js +1 -5
  12. package/dist/modules/access/access-control.service.js.map +1 -1
  13. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  14. package/dist/modules/audit/agent-audit.service.js +66 -6
  15. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  16. package/dist/modules/audit/audit.contract.d.ts +52 -8
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  18. package/dist/modules/audit/audit.contract.js +37 -1
  19. package/dist/modules/audit/audit.contract.js.map +1 -1
  20. package/dist/modules/audit/audit.routes.d.ts +1 -1
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  22. package/dist/modules/audit/audit.routes.js +15 -13
  23. package/dist/modules/audit/audit.routes.js.map +1 -1
  24. package/dist/modules/audit/request-audit.d.ts +21 -10
  25. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  26. package/dist/modules/audit/request-audit.js +26 -10
  27. package/dist/modules/audit/request-audit.js.map +1 -1
  28. package/dist/modules/database/core-schema.d.ts +22 -3
  29. package/dist/modules/database/core-schema.d.ts.map +1 -1
  30. package/dist/modules/database/core-schema.js +15 -6
  31. package/dist/modules/database/core-schema.js.map +1 -1
  32. package/dist/modules/kb-fs/index.d.ts +1 -0
  33. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  34. package/dist/modules/kb-fs/index.js +1 -0
  35. package/dist/modules/kb-fs/index.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  37. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  38. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  39. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  40. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  41. package/dist/modules/kb-fs/mutex.js +14 -0
  42. package/dist/modules/kb-fs/mutex.js.map +1 -1
  43. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  44. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  45. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  46. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  47. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  48. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp.routes.js +67 -0
  50. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  51. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  52. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  53. package/dist/modules/mcp/mcp.service.js +257 -50
  54. package/dist/modules/mcp/mcp.service.js.map +1 -1
  55. package/dist/modules/mcp/request-context.d.ts +25 -0
  56. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  57. package/dist/modules/mcp/request-context.js +11 -0
  58. package/dist/modules/mcp/request-context.js.map +1 -0
  59. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  60. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  61. package/dist/modules/mcp/shared-definitions.js +117 -0
  62. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  63. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  64. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  65. package/dist/modules/mcp/surface-cache.js +104 -0
  66. package/dist/modules/mcp/surface-cache.js.map +1 -0
  67. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  68. package/dist/modules/tool-helpers/tool-context.js +33 -12
  69. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  70. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  71. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  72. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  73. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  74. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  75. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +470 -106
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  85. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  86. package/dist/modules/workflow/read-on-branch.js +58 -0
  87. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  88. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  89. package/dist/modules/workflow/workflow.routes.js +22 -41
  90. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  91. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  92. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/workflow.service.js +163 -16
  94. package/dist/modules/workflow/workflow.service.js.map +1 -1
  95. package/dist/modules/workspace/index.d.ts +2 -0
  96. package/dist/modules/workspace/index.d.ts.map +1 -1
  97. package/dist/modules/workspace/index.js +4 -0
  98. package/dist/modules/workspace/index.js.map +1 -1
  99. package/dist/shared/git.contract.d.ts +7 -0
  100. package/dist/shared/git.contract.d.ts.map +1 -1
  101. package/dist/shared/git.contract.js +9 -0
  102. package/dist/shared/git.contract.js.map +1 -1
  103. package/dist/shared/no-link-below.d.ts +10 -0
  104. package/dist/shared/no-link-below.d.ts.map +1 -0
  105. package/dist/shared/no-link-below.js +30 -0
  106. package/dist/shared/no-link-below.js.map +1 -0
  107. package/migrations/0018_agent_events_page.sql +11 -0
  108. package/migrations/meta/0018_snapshot.json +2429 -0
  109. package/migrations/meta/_journal.json +8 -1
  110. package/package.json +3 -3
  111. package/src/core/catalog-revision.ts +3 -3
  112. package/src/core/create-core-server.ts +2 -1
  113. package/src/core/create-core-services.ts +4 -2
  114. package/src/modules/access/access-control.service.ts +1 -6
  115. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  116. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  117. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  118. package/src/modules/audit/agent-audit.service.ts +66 -6
  119. package/src/modules/audit/audit.contract.ts +62 -10
  120. package/src/modules/audit/audit.routes.ts +14 -12
  121. package/src/modules/audit/request-audit.ts +28 -13
  122. package/src/modules/database/core-schema.ts +15 -6
  123. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  124. package/src/modules/kb-fs/index.ts +1 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  126. package/src/modules/kb-fs/mutex.ts +16 -0
  127. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  128. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  129. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  130. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  131. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  132. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  133. package/src/modules/mcp/mcp.routes.ts +68 -0
  134. package/src/modules/mcp/mcp.service.ts +315 -51
  135. package/src/modules/mcp/request-context.ts +34 -0
  136. package/src/modules/mcp/shared-definitions.ts +121 -0
  137. package/src/modules/mcp/surface-cache.ts +154 -0
  138. package/src/modules/tool-helpers/tool-context.ts +37 -18
  139. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  140. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  141. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  142. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  143. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  144. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  145. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  147. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  148. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  149. package/src/modules/workflow/git/git.service.ts +537 -106
  150. package/src/modules/workflow/read-on-branch.ts +79 -0
  151. package/src/modules/workflow/workflow.routes.ts +21 -39
  152. package/src/modules/workflow/workflow.service.ts +203 -16
  153. package/src/modules/workspace/index.ts +11 -0
  154. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  155. package/src/shared/git.contract.ts +10 -0
  156. package/src/shared/no-link-below.ts +29 -0
@@ -61,13 +61,15 @@ import type { SpillStore } from '../workspace/spill-store.js';
61
61
  import type { HiddenToolSource } from '../../shared/hidden-tools.js';
62
62
  import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
63
63
  import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
64
- import type { IAgentEventRecorder } from '../audit/audit.contract.js';
65
- import { RequestAudit } from '../audit/request-audit.js';
64
+ import { RequestAudit, type IAgentEventRecorder, type RecordedPrincipal } from '../audit/index.js';
66
65
  import { ManualFailureMemo } from './manual-failure-memo.js';
67
66
  import { DownstreamPool, POOL_KEY_SEPARATOR, type DownstreamPoolOptions, type Lease } from './downstream-pool.js';
68
67
  import { SurfaceLogThrottle } from './surface-log-throttle.js';
69
68
  import { ToolSchemaGuard, type ScreenedHiddenTool } from './tool-schema-guard.js';
70
69
  import { DownstreamRefreshGuard, isDownstreamTokenRejection } from './downstream-token-refresh.js';
70
+ import { SurfaceCache, type SurfaceCacheOptions } from './surface-cache.js';
71
+ import { SharedDefinitions } from './shared-definitions.js';
72
+ import { currentRequest, runInRequest, type McpRequestContext } from './request-context.js';
71
73
  import { printable } from '../../shared/printable.js';
72
74
  import {
73
75
  composeAgentInstructions,
@@ -109,6 +111,11 @@ export interface McpProxyOptions {
109
111
  kbLayout?: () => KbLayout;
110
112
  /** Bounds of the downstream (`mcp.json`) connection pool; defaults are 4h idle / 5000 entries. */
111
113
  downstreamPool?: Pick<DownstreamPoolOptions<unknown>, 'idleTtlMs' | 'maxEntries' | 'now'>;
114
+ /**
115
+ * Bounds of the cache of built tool surfaces (see {@link SurfaceCache});
116
+ * defaults are 1h idle / 500 entries. `maxEntries: 0` builds per request.
117
+ */
118
+ surfaceCache?: SurfaceCacheOptions;
112
119
  /**
113
120
  * The scope this knowledge base's secrets vault was registered under (see
114
121
  * `secrets-variable-loader.ts`), so the UTCP clients built here resolve a
@@ -131,6 +138,18 @@ export interface McpCaller {
131
138
  connectionId?: string | null;
132
139
  /** The raw bearer the request carried. */
133
140
  bearer: string;
141
+ /**
142
+ * The scope the caller's credential grants. Every external credential is
143
+ * write-scoped today; carried so a surface built for one scope can never
144
+ * serve the other once a read-scoped key exists. Absent = `write`.
145
+ */
146
+ scope?: 'read' | 'write';
147
+ /**
148
+ * The knowledge-base path of the HTML page whose script made this request,
149
+ * as the person signed in to the app. The route accepts it from a session
150
+ * caller only. Its calls are recorded in the Audit log under the page.
151
+ */
152
+ page?: string | null;
134
153
  }
135
154
 
136
155
  /**
@@ -181,8 +200,39 @@ interface RequestSurface {
181
200
  * connection was shown.
182
201
  */
183
202
  hidden: ReadonlyMap<string, ScreenedHiddenTool>;
203
+ /**
204
+ * A manual other than the knowledge base's failed to load for a reason that
205
+ * may clear by itself within the minute — a token refresh that has not
206
+ * settled. Such a surface is cached only briefly, so the manual is tried
207
+ * again as soon as its refresh policy allows.
208
+ *
209
+ * A manual that failed for any other reason (a variable no one has set, a
210
+ * server that does not answer, a definition that does not parse) does NOT
211
+ * shorten the surface's life: on a knowledge base where one `.tool` is
212
+ * broken that would rebuild every person's client every minute. What mends
213
+ * such a manual reaches the cache another way — a secret set evicts the
214
+ * clients built without it, an edited `.tool` is a new catalog revision —
215
+ * and a server that was merely down is tried again on the person's next
216
+ * build, at the latest after an hour unused.
217
+ */
218
+ degraded: boolean;
184
219
  }
185
220
 
221
+ /**
222
+ * How long a surface missing a manual that failed is kept (see
223
+ * {@link RequestSurface.degraded}): the refresh policy's window, so the
224
+ * manual is retried as soon as a refresh may have settled.
225
+ */
226
+ const DEGRADED_SURFACE_MAX_AGE_MS = 60_000;
227
+
228
+ /**
229
+ * What the platform-hosted manuals' `${CONNECTION_KEY}` is seeded with when a
230
+ * cached client is built. Never sent anywhere: the variable is replaced by a
231
+ * getter that reads the bearer of the request a call belongs to (see
232
+ * {@link bindRequestBearer}).
233
+ */
234
+ const REQUEST_BEARER_PLACEHOLDER = 'request-bearer';
235
+
186
236
  /** A manual off the surface for want of a sign-in — see {@link RequestSurface.unavailable}. */
187
237
  interface UnavailableManual {
188
238
  /** The name as registered (`utcpManualName`): tools of it are `<utcpName>_<tool>` on the wire. */
@@ -232,20 +282,35 @@ interface PooledDownstream {
232
282
  * The MCP server is a GENERIC, STATELESS proxy over the UTCP tool surface. It
233
283
  * owns no tool logic and no session: every HTTP request gets its own MCP
234
284
  * `Server`, built from that request's bearer. When the request needs tools it
235
- * stands up a `UtcpClient` pointed at the backend's own `GET /api/agent/all-tools`
236
- * catalog (over loopback, authenticated as the caller, so ACL filtering lives
237
- * ONCE behind the REST surface), discovers every tool, and re-exposes each one
238
- * to the MCP client. A tool call is dispatched straight back through the same
285
+ * uses the caller's `UtcpClient` — pointed at the backend's own
286
+ * `GET /api/agent/all-tools` catalog (over loopback, authenticated as the
287
+ * caller, so ACL filtering lives ONCE behind the REST surface), every tool
288
+ * discovered — and re-exposes each one to the MCP client. A tool call is dispatched straight back through the same
239
289
  * REST endpoint the tool's UTCP `tool_call_template` names — so the agent logic,
240
290
  * thread continuity, and per-key token metering all live ONCE behind that REST
241
291
  * endpoint (`AgentAskService` for `ask`), never duplicated here.
242
292
  *
243
- * Because nothing is held between requests, a platform restart between two
244
- * requests of one client conversation is undetectable by the client.
293
+ * The client is built once per person, caller kind, key scope and catalog
294
+ * revision, and reused until one of those changes (see {@link SurfaceCache}):
295
+ * the revision is the fingerprint `hexis-mcp` already polls, so a `.tool`
296
+ * added, edited or put out of the caller's reach is a new revision and a new
297
+ * build, while a note saved anywhere is not. A secret changing evicts the
298
+ * person's clients (a shared one, everyone's). Building cost a full catalog
299
+ * fetch and discovery per request, which on a slow knowledge base was the
300
+ * difference between an answer and a client's discovery timeout.
245
301
  *
246
- * There is NO catalog cache at this layer, deliberately: the catalog is
247
- * re-derived per request, and if that ever proves too slow the fix belongs in
248
- * the source path (all-tools/ACL), which serves every other caller too.
302
+ * A cached client carries no request's bearer. Each request brings its own,
303
+ * through the request context (see `request-context.ts`): the
304
+ * platform-hosted manuals read it at call time, so a connection key's metering
305
+ * and audit stay its own, a revoked key fails on its next request while the
306
+ * person's client stays warm for their other key, and a token minted for one
307
+ * request never has to outlive it. The cache holds references and nothing the
308
+ * process could not rebuild, so a restart between two requests is still
309
+ * undetectable by the client: the first request after it rebuilds.
310
+ *
311
+ * Tool definitions are held once for every client that discovered the same
312
+ * ones (see {@link SharedDefinitions}), so hundreds of warm clients of one
313
+ * catalog cost one catalog's worth of schemas and descriptions.
249
314
  *
250
315
  * The one exception to "nothing between requests" is proxied third-party MCP
251
316
  * servers (`mcp` manuals): those are session-ful by nature, so their
@@ -300,6 +365,12 @@ export class McpService {
300
365
  // The downstream connection pool for `mcp` manuals — see the class doc.
301
366
  private readonly downstream: DownstreamPool<PooledDownstream>;
302
367
 
368
+ // Built surfaces, per caller and catalog revision — see the class doc.
369
+ private readonly surfaces: SurfaceCache<RequestSurface>;
370
+
371
+ // Tool definitions shared by every surface that discovered the same ones.
372
+ private readonly definitions = new SharedDefinitions();
373
+
303
374
  constructor(
304
375
  private readonly opts: McpProxyOptions,
305
376
  // The vault + manual catalog, used to check a caller's per-user credentials
@@ -342,26 +413,32 @@ export class McpService {
342
413
  });
343
414
  // Paced on the pool's clock, so a test that moves one moves both.
344
415
  this.tokenRefreshes = new DownstreamRefreshGuard(undefined, opts.downstreamPool?.now);
416
+ this.surfaces = new SurfaceCache<RequestSurface>(opts.surfaceCache);
345
417
  }
346
418
 
347
419
  /**
348
420
  * A secret changed — wired to the secrets vault's mutation listener.
349
421
  * `null` = a shared secret changed (affects everyone).
350
422
  *
351
- * Two things were built from the old value: remembered manual failures (so a
352
- * just-repaired credential is retried on the very next request) and pooled
353
- * downstream connections (dialed and discovered with the old credential).
354
- * Both are dropped; the next request rebuilds whatever it needs.
423
+ * Three things were built from the old value: remembered manual failures (so
424
+ * a just-repaired credential is retried on the very next request), pooled
425
+ * downstream connections (dialed and discovered with the old credential),
426
+ * and cached surfaces (whose tools, and whose list of manuals waiting on a
427
+ * sign-in, were decided with it). All are dropped; the next request
428
+ * rebuilds whatever it needs.
355
429
  */
356
430
  onSecretsChanged(userId: string | null): void {
357
431
  this.forgetStaleRefreshWindows(userId);
358
432
  if (userId === null) {
359
433
  this.manualFailures.clearAll();
360
434
  this.downstream.closeAll();
435
+ this.surfaces.clear();
361
436
  return;
362
437
  }
363
438
  this.manualFailures.clearUser(userId);
364
- this.downstream.evictWhere((key) => key.startsWith(`${userId}${POOL_KEY_SEPARATOR}`));
439
+ const mine = (key: string) => key.startsWith(`${userId}${POOL_KEY_SEPARATOR}`);
440
+ this.downstream.evictWhere(mine);
441
+ this.surfaces.evictWhere(mine);
365
442
  }
366
443
 
367
444
  /**
@@ -391,12 +468,14 @@ export class McpService {
391
468
  * Cheap by construction: nothing is fetched up front except what the
392
469
  * request's own messages need. The agent instructions are read only when the
393
470
  * request carries an `initialize` (the only place they ride); the tool
394
- * catalog is built on the first handler that needs it and shared by every
395
- * message of the request (a JSON-RPC batch), never beyond it.
471
+ * surface is looked up on the first handler that needs it — the caller's
472
+ * cached one, or a fresh build — and shared by every message of the request
473
+ * (a JSON-RPC batch).
396
474
  */
397
475
  async createRequestServer(caller: McpCaller, messages: unknown): Promise<Server> {
398
476
  const { userId, tokenId, bearer } = caller;
399
477
  const connectionId = caller.connectionId ?? null;
478
+ const page = caller.page ?? null;
400
479
  // The loopback surface (`/api/agent/*`) accepts connection keys and
401
480
  // internal tokens only. A connection-key request passes the caller's own
402
481
  // key through (per-key metering rides on it); an OAuth/JWT request's
@@ -413,11 +492,18 @@ export class McpService {
413
492
  let instructions: Promise<ComposedAgentInstructions> | undefined;
414
493
  const agentInstructions = () => (instructions ??= this.composeAgentInstructions());
415
494
  // The Audit log's view of this request — only when the request can be
416
- // attributed to a key or an agent connection. The catalog names it
417
- // classifies pooled tools by are the surface's, which is built lazily
418
- // below, so it reads them through a holder the surface fills in.
495
+ // attributed to a key, an agent connection or a page. The catalog names
496
+ // it classifies pooled tools by are the surface's, which is looked up
497
+ // lazily below, so it reads them through a holder the surface fills in.
419
498
  const catalogNamesHolder: { current: ReadonlyMap<string, string> } = { current: new Map() };
420
- const principal = tokenId != null ? { kind: 'key' as const, id: tokenId } : connectionId ? { kind: 'agent' as const, id: connectionId } : null;
499
+ const principal: RecordedPrincipal | null =
500
+ tokenId != null
501
+ ? { kind: 'key', id: tokenId }
502
+ : connectionId
503
+ ? { kind: 'agent', id: connectionId }
504
+ : page
505
+ ? { kind: 'page', path: page }
506
+ : null;
421
507
  const audit =
422
508
  this.auditRecorder && principal
423
509
  ? new RequestAudit(this.auditRecorder, userId, principal, {
@@ -429,12 +515,16 @@ export class McpService {
429
515
  ),
430
516
  })
431
517
  : null;
518
+ // What this request brings to a client it shares with the person's other
519
+ // requests: its own loopback bearer and its own audit. Every handler that
520
+ // reaches the client runs inside it.
521
+ const ctx: McpRequestContext = { loopbackBearer, audit };
522
+ const inRequest = <T>(fn: () => Promise<T>): Promise<T> => runInRequest(ctx, fn);
432
523
  let surface: Promise<RequestSurface> | undefined;
433
524
  const requestSurface = () =>
434
- (surface ??= this.buildSurface(userId, tokenId, loopbackBearer).then((built) => {
435
- catalogNamesHolder.current = built.catalogNames;
436
- audit?.instrumentChainCalls(built.client);
437
- return built;
525
+ (surface ??= inRequest(() => this.surfaceFor(caller, loopbackBearer)).then((found) => {
526
+ catalogNamesHolder.current = found.catalogNames;
527
+ return found;
438
528
  }));
439
529
 
440
530
  const initializing = (Array.isArray(messages) ? messages : [messages]).some((m) => isInitializeRequest(m));
@@ -462,7 +552,7 @@ export class McpService {
462
552
  },
463
553
  );
464
554
 
465
- server.setRequestHandler(ListToolsRequestSchema, async () => {
555
+ server.setRequestHandler(ListToolsRequestSchema, async () => inRequest(async () => {
466
556
  const [{ tools, catalogNames }, { toolPrefix }] = await Promise.all([requestSurface(), agentInstructions()]);
467
557
  // A discovered tool whose name collides with a meta-tool would be
468
558
  // listed but never callable (the dispatcher routes the name to the
@@ -565,7 +655,7 @@ export class McpService {
565
655
  // Code-mode meta-tools first, then every validated direct tool.
566
656
  tools: [...metaTools, ...direct],
567
657
  };
568
- });
658
+ }));
569
659
 
570
660
  // The MCP App views. Registered only when this deployment serves one, so
571
661
  // the handlers and the declared capability agree.
@@ -586,8 +676,12 @@ export class McpService {
586
676
  });
587
677
  }
588
678
 
589
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
590
- const { client, tools, unavailable, catalogNames, hidden: hiddenTools } = await requestSurface();
679
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => inRequest(async () => {
680
+ const { client: shared, tools, unavailable, catalogNames, hidden: hiddenTools } = await requestSurface();
681
+ // The person's client, as this request uses it: every call it makes —
682
+ // a chain's too, which comes back from the isolate outside any async
683
+ // context — carries this request's bearer and audit.
684
+ const client = requestView(shared, ctx);
591
685
  const toolName = request.params.name;
592
686
  const args = request.params.arguments ?? {};
593
687
  if (META_TOOL_NAMES.has(toolName)) {
@@ -670,7 +764,7 @@ export class McpService {
670
764
  const structured = apps !== null && Object.hasOwn(apps.tools, proxied.mcpName);
671
765
  const run = () => this.dispatch(client, proxied, request, extra, structured);
672
766
  return audit ? audit.call(proxied.utcpName, args, run, isErrorResult) : run();
673
- });
767
+ }));
674
768
 
675
769
  // Prompts = skills. Each skill becomes a user-callable prompt (slash command
676
770
  // in MCP clients). Backed by the same skill endpoints the tools use, over
@@ -709,22 +803,132 @@ export class McpService {
709
803
  }
710
804
 
711
805
  /**
712
- * The request's tool surface: catalog over loopback, a fresh UTCP client,
713
- * every manual registered (pooled for `mcp` ones), tools flattened.
806
+ * Record that the person viewing `page` refused it a call: the page asked to
807
+ * write, the app asked them, and they answered Deny. Nothing ran, so the
808
+ * call never reached this server as a `tools/call`; the app reports it here
809
+ * so the Audit log shows the page's attempt beside its allowed calls.
810
+ *
811
+ * The tool is named the way the page named it (its MCP name) and recorded
812
+ * the way that call would have been: through the person's surface, which
813
+ * the page's earlier calls have normally left warm.
814
+ */
815
+ async recordPageRefusal(
816
+ caller: Pick<McpCaller, 'userId' | 'bearer' | 'scope'>,
817
+ page: string,
818
+ toolName: string,
819
+ args: Record<string, unknown>,
820
+ ): Promise<void> {
821
+ if (!this.auditRecorder) return;
822
+ const { userId } = caller;
823
+ const loopbackBearer = this.internalTokens
824
+ ? this.internalTokens.mint({ userId, externalProxy: true }, MCP_LOOPBACK_TOKEN_TTL_MS)
825
+ : caller.bearer;
826
+ let catalogNames: ReadonlyMap<string, string> = new Map();
827
+ const audit = new RequestAudit(this.auditRecorder, userId, { kind: 'page', path: page }, {
828
+ kbManualName: EXTERNAL_KB_MANUAL_NAME,
829
+ catalogNames: () => catalogNames,
830
+ loadSkills: () =>
831
+ this.fetchSkillList(loopbackBearer).then((skills) =>
832
+ skills.flatMap((s) => (s.path ? [{ name: s.name, path: s.path }] : [])),
833
+ ),
834
+ });
835
+ const ctx: McpRequestContext = { loopbackBearer, audit };
836
+ await runInRequest(ctx, async () => {
837
+ let utcpName = toolName;
838
+ if (!META_TOOL_NAMES.has(toolName)) {
839
+ const surface = await this.surfaceFor({ ...caller, tokenId: null }, loopbackBearer);
840
+ catalogNames = surface.catalogNames;
841
+ utcpName =
842
+ surface.tools.find((t) => t.mcpName === toolName)?.utcpName ?? surface.hidden.get(toolName)?.utcpName ?? toolName;
843
+ }
844
+ await audit.denied(utcpName, args, 'refused');
845
+ });
846
+ }
847
+
848
+ /**
849
+ * The caller's tool surface: the one cached for their catalog revision, or
850
+ * a fresh build that is then cached.
851
+ *
852
+ * The key is the person, the kind of caller (a connection key, or a person
853
+ * through OAuth or their session — the latter two reach the same loopback
854
+ * identity), the credential's scope, and the catalog revision. The revision
855
+ * is asked for on every request, with the request's own bearer, so it is
856
+ * the caller's CURRENT catalog that picks the surface: a manual released,
857
+ * edited or put out of their reach moves it, and the request builds anew.
858
+ *
859
+ * When the revision cannot be read the request builds a surface of its own
860
+ * and keeps it to itself — the per-request behaviour this replaced, never a
861
+ * surface it cannot tell is current.
862
+ */
863
+ private async surfaceFor(caller: McpCaller, loopbackBearer: string): Promise<RequestSurface> {
864
+ const { userId, tokenId } = caller;
865
+ const revision = await this.fetchCatalogRevision(loopbackBearer);
866
+ if (revision === null) return this.buildSurface(userId, tokenId, loopbackBearer, 'uncached');
867
+ const key = [userId, tokenId != null ? 'key' : 'person', caller.scope ?? 'write', revision].join(POOL_KEY_SEPARATOR);
868
+ const { value } = await this.surfaces.get(
869
+ key,
870
+ () => this.buildSurface(userId, tokenId, loopbackBearer, 'miss'),
871
+ (built) => (built.degraded ? DEGRADED_SURFACE_MAX_AGE_MS : undefined),
872
+ );
873
+ return value;
874
+ }
875
+
876
+ /**
877
+ * The caller's catalog revision (`GET /api/agent/catalog-revision`, the one
878
+ * `hexis-mcp` polls), read with their bearer; null when it cannot be read.
879
+ * A deployment or a test that does not serve the route answers 404, which
880
+ * is not worth a line in the log; anything else is.
881
+ */
882
+ private async fetchCatalogRevision(bearer: string): Promise<string | null> {
883
+ try {
884
+ const res = await fetch(`${this.opts.loopbackBaseUrl}/api/agent/catalog-revision`, {
885
+ headers: { Authorization: `Bearer ${bearer}` },
886
+ signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
887
+ });
888
+ if (!res.ok) {
889
+ if (res.status !== 404) log.warn(`catalog-revision loopback failed: HTTP ${res.status}; building this request's surface uncached`);
890
+ return null;
891
+ }
892
+ const body = (await res.json()) as { revision?: unknown };
893
+ return typeof body.revision === 'string' && body.revision ? body.revision : null;
894
+ } catch (err) {
895
+ log.warn("catalog-revision loopback threw; building this request's surface uncached:", { err });
896
+ return null;
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Build a tool surface: catalog over loopback, a UTCP client, every manual
902
+ * registered (pooled for `mcp` ones), tools flattened, definitions shared.
714
903
  *
715
- * Logs one line with the time each half took — the per-request overhead
716
- * the stateless design trades for restart-invisibility, kept visible so it
717
- * is measured rather than assumed.
904
+ * Runs inside the request that asked for it, whose bearer the knowledge
905
+ * base's discovery reads; the client it builds keeps none of it.
906
+ *
907
+ * Logs one line with the time each half took and whether it was a cache
908
+ * miss, so a build is measured rather than assumed, and a log that shows
909
+ * one line for two requests shows the second was served warm.
718
910
  */
719
- private async buildSurface(userId: string, tokenId: string | null, loopbackBearer: string): Promise<RequestSurface> {
911
+ private async buildSurface(
912
+ userId: string,
913
+ tokenId: string | null,
914
+ loopbackBearer: string,
915
+ cache: 'miss' | 'uncached',
916
+ ): Promise<RequestSurface> {
720
917
  const started = performance.now();
721
918
  // The list of manuals this caller gets: the KB manual + each `.tool` they
722
919
  // can read. Fetched over loopback with the caller's key, so ACL filtering
723
920
  // lives ONCE behind the REST surface.
724
921
  const manuals = await this.fetchManualTemplates(loopbackBearer);
725
922
  const catalogMs = performance.now() - started;
726
- const client = await this.buildClient(loopbackBearer, userId, manuals);
727
- const { tools, unavailable, catalogNames, hidden } = await this.discoverTools(client, manuals, userId);
923
+ const client = await this.buildClient(userId, manuals);
924
+ const discovered = await this.discoverTools(client, manuals, userId);
925
+ // After discovery, which renames each template in place to the name it
926
+ // registered under: that is the name the repository keys it by.
927
+ const rawTemplates = new Map(manuals.map((m) => [utcpManualName(m), m] as const));
928
+ await this.definitions.share(client, rawTemplates);
929
+ // Chain calls are audited per request, by whichever request makes them.
930
+ RequestAudit.instrumentChainCalls(client, () => currentRequest()?.audit);
931
+ const tools = this.definitions.listing(discovered.tools);
728
932
  const totalMs = performance.now() - started;
729
933
  // Per user: on a shape change or once per interval, never per request —
730
934
  // see SurfaceLogThrottle for why both halves matter.
@@ -733,13 +937,13 @@ export class McpService {
733
937
  log.info(
734
938
  // Both ids come from the request (an identity provider's subject, a
735
939
  // connection key's id), so neither is interpolated raw — see printable.
736
- `request surface: user=${printable(userId)} tokenId=${tokenId ? printable(tokenId) : 'none'} — ` +
940
+ `request surface: user=${printable(userId)} tokenId=${tokenId ? printable(tokenId) : 'none'} cache=${cache} — ` +
737
941
  `${tools.length} tool(s) across ${manuals.length} manual(s) in ${totalMs.toFixed(0)}ms ` +
738
942
  `(catalog ${catalogMs.toFixed(0)}ms, registration ${(totalMs - catalogMs).toFixed(0)}ms)` +
739
943
  (decision.suppressed > 0 ? ` [+${decision.suppressed} identical rebuild(s) since last line]` : ''),
740
944
  );
741
945
  }
742
- return { client, tools, unavailable, catalogNames, hidden };
946
+ return { ...discovered, client, tools };
743
947
  }
744
948
 
745
949
  /**
@@ -857,7 +1061,7 @@ export class McpService {
857
1061
  }
858
1062
 
859
1063
  /**
860
- * One `CodeModeUtcpClient` per request. Reserved `API_URL`/`CONNECTION_KEY` are
1064
+ * One `CodeModeUtcpClient` per surface. Reserved `API_URL`/`CONNECTION_KEY` are
861
1065
  * seeded (namespaced) ONLY for Bevel-hosted manuals — the KB manual and inline
862
1066
  * `.tool` sub-manuals, whose discovery template targets the loopback (`${API_URL}`).
863
1067
  * Third-party http/mcp `.tool`s point at arbitrary user URLs, so we must NOT seed
@@ -865,13 +1069,23 @@ export class McpService {
865
1069
  * would otherwise exfiltrate the caller's token to its own endpoint. Tool
866
1070
  * `${SECRET}` refs resolve lazily via the per-user `bevel-secrets` loader.
867
1071
  *
868
- * Not `close()`d: a request's client holds no connection of its own (its
869
- * `mcp` manuals route to the pool, so its own `mcp` protocol instance never
870
- * opens a session), and dropping the reference is the whole teardown.
1072
+ * `CONNECTION_KEY` is seeded as a placeholder and bound to the request
1073
+ * context (see {@link bindRequestBearer}): the client is shared by the
1074
+ * person's requests, and each call carries the bearer of the request it
1075
+ * belongs to.
1076
+ *
1077
+ * Not `close()`d: a client holds no connection of its own (its `mcp`
1078
+ * manuals route to the pool, so its own `mcp` protocol instance never opens
1079
+ * a session), and dropping the reference is the whole teardown.
871
1080
  */
872
- private async buildClient(bearer: string, userId: string, manuals: CallTemplate[]): Promise<CodeModeUtcpClient> {
873
- const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, bearer);
874
- return CodeModeUtcpClient.create(process.cwd(), utcpClientConfig(userId, variables, this.opts.secretsScope));
1081
+ private async buildClient(userId: string, manuals: CallTemplate[]): Promise<CodeModeUtcpClient> {
1082
+ const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl, REQUEST_BEARER_PLACEHOLDER);
1083
+ const client = await CodeModeUtcpClient.create(
1084
+ process.cwd(),
1085
+ utcpClientConfig(userId, variables, this.opts.secretsScope),
1086
+ );
1087
+ bindRequestBearer(client);
1088
+ return client;
875
1089
  }
876
1090
 
877
1091
  /**
@@ -886,8 +1100,8 @@ export class McpService {
886
1100
  * pool (see {@link attachDownstream}).
887
1101
  *
888
1102
  * Failures are memoized per (user, manual definition) for a few minutes (see
889
- * {@link ManualFailureMemo}): the catalog is rebuilt on every request, and a
890
- * manual with a broken credential (expired OAuth, revoked key) would
1103
+ * {@link ManualFailureMemo}): a surface is rebuilt when its catalog
1104
+ * revision or the caller's secrets change (see {@link RequestSurface.degraded}), and a manual with a broken credential (expired OAuth, revoked key) would
891
1105
  * otherwise re-dial its provider on every single one. The definition's
892
1106
  * fingerprint is part of the key, so a manual edited after a failure (a
893
1107
  * corrected `mcp.json` URL) is tried on the very next request.
@@ -901,8 +1115,11 @@ export class McpService {
901
1115
  unavailable: UnavailableManual[];
902
1116
  catalogNames: Map<string, string>;
903
1117
  hidden: Map<string, ScreenedHiddenTool>;
1118
+ degraded: boolean;
904
1119
  }> {
905
1120
  const routes = new Map<string, DownstreamRoute>();
1121
+ // A manual left off this surface for a failure (see RequestSurface.degraded).
1122
+ let degraded = false;
906
1123
  const unavailable: UnavailableManual[] = [];
907
1124
  // Taken BEFORE a single manual is registered, so it ranks this load by the
908
1125
  // freshness of what it is about to read — see ToolSchemaGuard.beginLoad.
@@ -992,6 +1209,7 @@ export class McpService {
992
1209
  // policy's one and hold the manual down after the provider is back.
993
1210
  if (!result.retryable) this.manualFailures.recordFailure(userId, memoKey, result.error, generation);
994
1211
  if (result.signInMissing) unavailable.push({ utcpName: rewritten });
1212
+ else if (result.retryable) degraded = true;
995
1213
  log.warn(`skipping manual "${name}": ${result.error}`);
996
1214
  } else if (!isKb) {
997
1215
  this.manualFailures.clear(userId, memoKey);
@@ -1008,7 +1226,7 @@ export class McpService {
1008
1226
  const utcpTools = await client.getTools();
1009
1227
  const flattened = utcpTools.map((tool: UtcpTool) => flattenManualTool(tool, EXTERNAL_KB_MANUAL_NAME));
1010
1228
  const { tools, hidden } = await this.hideInvalidSchemas(client, flattened, manualCatalogNames, userId, loadId);
1011
- return { tools, unavailable, catalogNames, hidden };
1229
+ return { tools, unavailable, catalogNames, hidden, degraded };
1012
1230
  }
1013
1231
 
1014
1232
  /**
@@ -1581,6 +1799,52 @@ function routeToDownstream(client: CodeModeUtcpClient, routes: ReadonlyMap<strin
1581
1799
  };
1582
1800
  }
1583
1801
 
1802
+ /**
1803
+ * The person's shared client as ONE request uses it: a view whose `callTool`
1804
+ * runs inside that request's context, and which is otherwise the client
1805
+ * itself (the prototype), so its repository, protocols and routing are the
1806
+ * shared ones.
1807
+ *
1808
+ * `callTool` is the entry `call_tool_chain` bridges every in-isolate tool
1809
+ * call to, and those calls come back from isolated-vm's callback queue with
1810
+ * no async context of their own: without the view they would run as no
1811
+ * request at all, with no bearer and no audit. The direct path
1812
+ * (`callToolStreaming`) runs in the request's handler and needs nothing
1813
+ * more.
1814
+ */
1815
+ function requestView(client: CodeModeUtcpClient, ctx: McpRequestContext): CodeModeUtcpClient {
1816
+ const view = Object.create(client) as CodeModeUtcpClient;
1817
+ view.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
1818
+ runInRequest(ctx, () => client.callTool(toolName, toolArgs));
1819
+ return view;
1820
+ }
1821
+
1822
+ /**
1823
+ * Make every platform-hosted manual's `${CONNECTION_KEY}` the bearer of the
1824
+ * request a call belongs to.
1825
+ *
1826
+ * The UTCP client substitutes a tool's `${VAR}`s each time the tool is
1827
+ * called, from `config.variables` first — so a getter there is read at call
1828
+ * time, never at build time. A call outside any request has no bearer to
1829
+ * send, and is refused here rather than sent with the placeholder.
1830
+ */
1831
+ function bindRequestBearer(client: CodeModeUtcpClient): void {
1832
+ const variables = client.config.variables ?? {};
1833
+ for (const [key, value] of Object.entries(variables)) {
1834
+ if (!key.endsWith('_CONNECTION_KEY') || value !== REQUEST_BEARER_PLACEHOLDER) continue;
1835
+ Object.defineProperty(variables, key, {
1836
+ enumerable: true,
1837
+ configurable: false,
1838
+ get: () => {
1839
+ const request = currentRequest();
1840
+ if (!request) throw new Error('A platform tool was called outside an MCP request, so there is no bearer to call it with.');
1841
+ return request.loopbackBearer;
1842
+ },
1843
+ });
1844
+ }
1845
+ client.config.variables = variables;
1846
+ }
1847
+
1584
1848
  /**
1585
1849
  * The pieces of the proxy that are shared with the local MCP server now live in
1586
1850
  * `@bevel-software/platform-mcp-core`. Re-exported here because this module is
@@ -0,0 +1,34 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { RequestAudit } from '../audit/index.js';
3
+
4
+ /**
5
+ * What ONE MCP request brings to the tool client it runs on: the bearer its
6
+ * loopback calls carry, and the Audit log's view of it.
7
+ *
8
+ * The client is cached across requests (see {@link SurfaceCache}), so neither
9
+ * may live on the client. A connection-key request's loopback bearer is its
10
+ * own key — per-key metering rides on it, and a revoked key must stop working
11
+ * on its very next request even while the person's client is warm. An OAuth
12
+ * or session request's bearer is an internal token minted for that request,
13
+ * with a lifetime: baked into a cached client it would outlive nothing and
14
+ * expire under a page kept open for a day. So the client reads both from
15
+ * here, at call time, and a request supplies its own.
16
+ */
17
+ export interface McpRequestContext {
18
+ /** The bearer this request's loopback calls carry (`${CONNECTION_KEY}` of every platform-hosted manual). */
19
+ loopbackBearer: string;
20
+ /** The request's Audit log view, or null when the request is attributed to nothing. */
21
+ audit: RequestAudit | null;
22
+ }
23
+
24
+ const storage = new AsyncLocalStorage<McpRequestContext>();
25
+
26
+ /** Run `fn` as part of the request `ctx`: every tool call it makes, however deep, reads `ctx`. */
27
+ export function runInRequest<T>(ctx: McpRequestContext, fn: () => T): T {
28
+ return storage.run(ctx, fn);
29
+ }
30
+
31
+ /** The request the current call belongs to, or undefined outside one. */
32
+ export function currentRequest(): McpRequestContext | undefined {
33
+ return storage.getStore();
34
+ }