@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
|
@@ -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/
|
|
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
|
-
*
|
|
50
|
-
* catalog (over loopback, authenticated as the
|
|
51
|
-
* ONCE behind the REST surface),
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
*
|
|
173
|
-
* just-repaired credential is retried on the very next request)
|
|
174
|
-
* downstream connections (dialed and discovered with the old credential)
|
|
175
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
212
|
-
*
|
|
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
|
|
232
|
-
// classifies pooled tools by are the surface's, which is
|
|
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
|
|
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.
|
|
245
|
-
catalogNamesHolder.current =
|
|
246
|
-
|
|
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
|
-
*
|
|
497
|
-
*
|
|
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
|
-
*
|
|
500
|
-
* the
|
|
501
|
-
*
|
|
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
|
|
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(
|
|
511
|
-
const
|
|
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
|
|
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
|
|
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
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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(
|
|
651
|
-
const variables = seedBevelHostedManualVars(manuals, this.opts.loopbackBaseUrl,
|
|
652
|
-
|
|
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}):
|
|
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
|