@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.
- package/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +4 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +4 -2
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- 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
|
|
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
|
-
*
|
|
236
|
-
* catalog (over loopback, authenticated as the
|
|
237
|
-
* ONCE behind the REST surface),
|
|
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
|
-
*
|
|
244
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
-
*
|
|
352
|
-
* just-repaired credential is retried on the very next request)
|
|
353
|
-
* downstream connections (dialed and discovered with the old credential)
|
|
354
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
395
|
-
*
|
|
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
|
|
417
|
-
// classifies pooled tools by are the surface's, which is
|
|
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
|
|
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.
|
|
435
|
-
catalogNamesHolder.current =
|
|
436
|
-
|
|
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
|
-
*
|
|
713
|
-
*
|
|
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
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
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(
|
|
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(
|
|
727
|
-
const
|
|
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
|
|
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
|
|
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
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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(
|
|
873
|
-
const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl,
|
|
874
|
-
|
|
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}):
|
|
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
|
+
}
|