@bevel-software/platform-core-backend 0.19.0 → 0.20.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +22 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +10 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +414 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +99 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +6 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +8 -1
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +9 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -5
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +32 -6
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +22 -9
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +8 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +9 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +49 -13
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git-internals.d.ts +7 -1
- package/dist/shared/git-internals.d.ts.map +1 -1
- package/dist/shared/git-internals.js +67 -9
- package/dist/shared/git-internals.js.map +1 -1
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/core/create-core-server.ts +28 -0
- package/src/core/create-core-services.ts +13 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/database/core-schema.ts +104 -0
- package/src/modules/diff/diff.service.ts +6 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
- package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
- package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
- package/src/modules/mcp/mcp.routes.ts +11 -1
- package/src/modules/mcp/mcp.service.ts +62 -4
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
- package/src/modules/settings/deployment-settings.service.ts +47 -8
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
- package/src/modules/tool-auth/external-api-key.service.ts +25 -8
- package/src/modules/tool-auth/internal-token.service.ts +11 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
- package/src/modules/workspace/workspace.service.ts +9 -0
- package/src/modules/workspace/workspace.tools.ts +47 -11
- package/src/shared/__tests__/git-internals.test.ts +41 -0
- package/src/shared/git-internals.ts +61 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js +0 -61
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- package/dist/shared/kb-walk.js.map +0 -1
|
@@ -48,6 +48,8 @@ function makeExternalApiKeyService(
|
|
|
48
48
|
|
|
49
49
|
function makeOAuthProvider(
|
|
50
50
|
verify?: (t: string) => Promise<{ extra?: Record<string, unknown> }>,
|
|
51
|
+
/** Whether an agent connection still admits requests — true unless a test says otherwise. */
|
|
52
|
+
live?: (connectionId: string) => Promise<boolean>,
|
|
51
53
|
) {
|
|
52
54
|
return {
|
|
53
55
|
looksLikeAccessToken: (t: string) => typeof t === 'string' && t.startsWith('bevel-mcp_'),
|
|
@@ -57,6 +59,8 @@ function makeOAuthProvider(
|
|
|
57
59
|
throw new InvalidTokenError('unknown token');
|
|
58
60
|
}),
|
|
59
61
|
),
|
|
62
|
+
isConnectionLive: vi.fn(live ?? (async () => true)),
|
|
63
|
+
noteConnectionUse: vi.fn(),
|
|
60
64
|
} as unknown as BevelOAuthProvider;
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -179,7 +183,7 @@ describe('createMcpAuthMiddleware', () => {
|
|
|
179
183
|
const auth = makeAuthService();
|
|
180
184
|
const externalApiKeys = makeExternalApiKeyService();
|
|
181
185
|
const oauth = makeOAuthProvider(async () => ({
|
|
182
|
-
extra: { userId: 'user-5', userEmail: 'eve@example.com' },
|
|
186
|
+
extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: 'conn-5' },
|
|
183
187
|
}));
|
|
184
188
|
const mw = makeMw({ auth, keys: externalApiKeys, oauth });
|
|
185
189
|
const { req, res, next } = makeReqRes('Bearer bevel-mcp_token123');
|
|
@@ -188,14 +192,28 @@ describe('createMcpAuthMiddleware', () => {
|
|
|
188
192
|
|
|
189
193
|
expect(req.userId).toBe('user-5');
|
|
190
194
|
expect(req.userEmail).toBe('eve@example.com');
|
|
191
|
-
// OAuth sessions are unmetered like JWT sessions — no connection key id
|
|
195
|
+
// OAuth sessions are unmetered like JWT sessions — no connection key id…
|
|
192
196
|
expect(req.externalApiKeyId).toBeUndefined();
|
|
197
|
+
// …but they ARE attributed to the agent behind the grant, for the Audit log.
|
|
198
|
+
expect(req.agentConnectionId).toBe('conn-5');
|
|
193
199
|
expect(next).toHaveBeenCalled();
|
|
194
200
|
// Neither the connection-key nor the JWT path may see this token shape.
|
|
195
201
|
expect((externalApiKeys as any).verifyAndLoadToken).not.toHaveBeenCalled();
|
|
196
202
|
expect((auth as any).verifyToken).not.toHaveBeenCalled();
|
|
197
203
|
});
|
|
198
204
|
|
|
205
|
+
it('binds no agent for an OAuth token minted before connections existed, and still admits it', async () => {
|
|
206
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
207
|
+
extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: null },
|
|
208
|
+
}));
|
|
209
|
+
const mw = makeMw({ oauth });
|
|
210
|
+
const { req, res, next } = makeReqRes('Bearer bevel-mcp_token123');
|
|
211
|
+
await mw(req, res, next);
|
|
212
|
+
expect(req.userId).toBe('user-5');
|
|
213
|
+
expect(req.agentConnectionId).toBeUndefined();
|
|
214
|
+
expect(next).toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
199
217
|
it('401s with the discovery challenge when the OAuth token is invalid/expired/revoked', async () => {
|
|
200
218
|
const mw = makeMw();
|
|
201
219
|
const { req, res, next, setHeader, status } = makeReqRes('Bearer bevel-mcp_expired');
|
|
@@ -276,6 +294,46 @@ describe('createMcpAuthMiddleware', () => {
|
|
|
276
294
|
expect(next).toHaveBeenCalled();
|
|
277
295
|
expect(req.userId).toBe('user-7');
|
|
278
296
|
expect(req.userEmail).toBe('seven@example.com');
|
|
297
|
+
// A plain loopback identity names no agent.
|
|
298
|
+
expect(req.agentConnectionId).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('carries the agent connection of an exchanged grant, so the local server stays attributed', async () => {
|
|
302
|
+
const token = internal.mint({ userId: 'user-7', externalProxy: true, connectionId: 'conn-7' }, 60_000);
|
|
303
|
+
const auth = makeAuthService();
|
|
304
|
+
(auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
|
|
305
|
+
id: 'user-7',
|
|
306
|
+
email: 'seven@example.com',
|
|
307
|
+
}));
|
|
308
|
+
const oauth = makeOAuthProvider();
|
|
309
|
+
const mw = makeMw({ internal, auth, oauth });
|
|
310
|
+
const { req, res, next } = makeReqRes(`Bearer ${token}`);
|
|
311
|
+
await mw(req, res, next);
|
|
312
|
+
expect(next).toHaveBeenCalled();
|
|
313
|
+
expect(req.agentConnectionId).toBe('conn-7');
|
|
314
|
+
// Every request through the exchanged grant asks whether the
|
|
315
|
+
// connection still admits one, and counts as a use of it.
|
|
316
|
+
expect((oauth as any).isConnectionLive).toHaveBeenCalledWith('conn-7');
|
|
317
|
+
expect((oauth as any).noteConnectionUse).toHaveBeenCalledWith('conn-7');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('401s an exchanged grant whose agent connection was revoked — the token is stateless, the connection is not', async () => {
|
|
321
|
+
const token = internal.mint({ userId: 'user-7', externalProxy: true, connectionId: 'conn-7' }, 60_000);
|
|
322
|
+
const auth = makeAuthService();
|
|
323
|
+
(auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
|
|
324
|
+
id: 'user-7',
|
|
325
|
+
email: 'seven@example.com',
|
|
326
|
+
}));
|
|
327
|
+
const oauth = makeOAuthProvider(undefined, async () => false);
|
|
328
|
+
const mw = makeMw({ internal, auth, oauth });
|
|
329
|
+
const { req, res, next, status, setHeader } = makeReqRes(`Bearer ${token}`);
|
|
330
|
+
await mw(req, res, next);
|
|
331
|
+
expect(next).not.toHaveBeenCalled();
|
|
332
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
333
|
+
expect(setHeader).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('resource_metadata'));
|
|
334
|
+
expect(req.agentConnectionId).toBeUndefined();
|
|
335
|
+
// A refused request is no use of the connection.
|
|
336
|
+
expect((oauth as any).noteConnectionUse).not.toHaveBeenCalled();
|
|
279
337
|
});
|
|
280
338
|
|
|
281
339
|
it('401s an expired internal token', async () => {
|
|
@@ -74,7 +74,7 @@ async function exchange(baseUrl: string, authorization?: string): Promise<Respon
|
|
|
74
74
|
describe('POST /mcp/local-token', () => {
|
|
75
75
|
it('exchanges a valid OAuth access token for an internal token with the loopback identity + TTL', async () => {
|
|
76
76
|
const oauth = makeOAuthProvider(async () => ({
|
|
77
|
-
extra: { userId: 'user-5', userEmail: 'eve@example.com' },
|
|
77
|
+
extra: { userId: 'user-5', userEmail: 'eve@example.com', connectionId: 'conn-9' },
|
|
78
78
|
// A grant with plenty of life left: the loopback constant is the binding cap.
|
|
79
79
|
expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
|
|
80
80
|
}));
|
|
@@ -88,21 +88,41 @@ describe('POST /mcp/local-token', () => {
|
|
|
88
88
|
// above outlives it, so the constant is the cap that binds.
|
|
89
89
|
expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
|
|
90
90
|
// The minted token verifies to the resolved user with the externalProxy
|
|
91
|
-
// flag — identical shape to the hosted session's loopback bearer
|
|
92
|
-
|
|
91
|
+
// flag — identical shape to the hosted session's loopback bearer — plus
|
|
92
|
+
// the grant's agent connection, so the local server's calls stay
|
|
93
|
+
// attributed to that agent in the Audit log.
|
|
94
|
+
expect(internalTokens.verify(body.token)).toEqual({
|
|
95
|
+
userId: 'user-5',
|
|
96
|
+
externalProxy: true,
|
|
97
|
+
connectionId: 'conn-9',
|
|
98
|
+
});
|
|
93
99
|
// …and the tool-auth verifier resolves it to source 'external', exactly
|
|
94
100
|
// how /api/agent/* will treat the local server.
|
|
95
101
|
const verify = createTokenVerifier(
|
|
96
102
|
{ looksLikeExternalApiKey: () => false } as unknown as IExternalApiKeyService,
|
|
97
103
|
internalTokens,
|
|
98
104
|
);
|
|
105
|
+
// …carrying the agent connection, which the REST recorder attributes
|
|
106
|
+
// the local server's direct tool calls to.
|
|
99
107
|
await expect(verify(body.token)).resolves.toEqual({
|
|
100
108
|
ok: true,
|
|
101
|
-
auth: { source: 'external', userId: 'user-5', scope: 'write' },
|
|
109
|
+
auth: { source: 'external', userId: 'user-5', connectionId: 'conn-9', scope: 'write' },
|
|
102
110
|
});
|
|
103
111
|
expect((oauth as any).verifyAccessToken).toHaveBeenCalledWith('bevel-mcp_valid123');
|
|
104
112
|
});
|
|
105
113
|
|
|
114
|
+
it('mints without a connection claim for a grant that carries none (a token from before connections existed)', async () => {
|
|
115
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
116
|
+
extra: { userId: 'user-5', userEmail: 'eve@example.com' },
|
|
117
|
+
expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
|
|
118
|
+
}));
|
|
119
|
+
const { baseUrl, internalTokens } = await mount(oauth);
|
|
120
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_valid123');
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
const body = (await res.json()) as { token: string };
|
|
123
|
+
expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
106
126
|
/**
|
|
107
127
|
* The binding that keeps the exchange from OUTLIVING its grant: an access
|
|
108
128
|
* token with less life left than the loopback constant caps the minted
|
|
@@ -108,7 +108,7 @@ describe('POST /mcp (stateless)', () => {
|
|
|
108
108
|
expect((await messagesOf(res))[0].result.serverInfo.name).toBe('stub');
|
|
109
109
|
// The messages reach the service, so it can tell an initialize apart.
|
|
110
110
|
expect(mcpService.createRequestServer).toHaveBeenCalledWith(
|
|
111
|
-
{ userId: 'user-A', tokenId: null, bearer: '' },
|
|
111
|
+
{ userId: 'user-A', tokenId: null, connectionId: null, bearer: '' },
|
|
112
112
|
INITIALIZE,
|
|
113
113
|
);
|
|
114
114
|
});
|
|
@@ -12,6 +12,7 @@ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
|
|
|
12
12
|
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
13
13
|
import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
14
14
|
import { PLATFORM_HEADER, TOOL_PREFIX_LINE } from '../../agent-instructions/index.js';
|
|
15
|
+
import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.contract.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* End-to-end proxy test: a real express app serving the registry-driven tool
|
|
@@ -43,6 +44,10 @@ async function setup(deps?: {
|
|
|
43
44
|
readAgentPreamble?: () => Promise<string | null>;
|
|
44
45
|
/** Extra echo tools to register under these names (the four KB tools, in the prefix tests). */
|
|
45
46
|
extraTools?: string[];
|
|
47
|
+
/** The Audit log's recorder, when a test watches what gets recorded. */
|
|
48
|
+
auditRecorder?: IAgentEventRecorder;
|
|
49
|
+
/** The agent connection an OAuth caller arrived through (with `tokenId: null`). */
|
|
50
|
+
connectionId?: string | null;
|
|
46
51
|
}) {
|
|
47
52
|
const registry = new ToolRegistry();
|
|
48
53
|
registry.registerExternalTool(
|
|
@@ -91,6 +96,16 @@ async function setup(deps?: {
|
|
|
91
96
|
res.json({ text: `echo: ${b.prompt}`, sessionId: typeof b.sessionId === 'string' ? b.sessionId : 'new-sess' });
|
|
92
97
|
});
|
|
93
98
|
app.post('/api/agent/tools/boom', (_req, res) => res.status(500).json({ error: 'kaboom' }));
|
|
99
|
+
// The two the Audit log's skill classification reads through: a catalog of
|
|
100
|
+
// one skill, and a file read that answers for any path (registered as
|
|
101
|
+
// tools only by the tests that need them, via `extraTools`).
|
|
102
|
+
app.post('/api/agent/tools/list_skills', (_req, res) =>
|
|
103
|
+
res.json({ skills: [{ name: 'rfi', description: 'RFI answers', path: 'Plugins/Sales/rfi' }] }),
|
|
104
|
+
);
|
|
105
|
+
app.post('/api/agent/tools/read_file', (req, res) => {
|
|
106
|
+
const b = (req.body ?? {}) as { path?: string };
|
|
107
|
+
res.json({ content: `contents of ${b.path}` });
|
|
108
|
+
});
|
|
94
109
|
// A live UTCP manual a catalog entry can point at — one tool, dispatched back
|
|
95
110
|
// to the echo above. Mounted always (it costs one route) so a test can add a
|
|
96
111
|
// MANUAL to the served catalog, not merely a tool to the registry: what a
|
|
@@ -149,10 +164,11 @@ async function setup(deps?: {
|
|
|
149
164
|
deps?.toolManuals as any,
|
|
150
165
|
undefined, // internalTokens — the fake loopback here accepts any bearer
|
|
151
166
|
deps?.revokeOAuthAccess,
|
|
167
|
+
deps?.auditRecorder,
|
|
152
168
|
);
|
|
153
169
|
lastService = mcp;
|
|
154
170
|
const tokenId = deps?.tokenId === undefined ? 'tok-1' : deps.tokenId;
|
|
155
|
-
return connectClient(mcp, tokenId);
|
|
171
|
+
return connectClient(mcp, tokenId, deps?.connectionId ?? null);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
/** The service `setup` built last — for a test that needs a SECOND request against the same service. */
|
|
@@ -165,9 +181,9 @@ let lastService: McpService | undefined;
|
|
|
165
181
|
* The initialize message is passed so the server carries instructions,
|
|
166
182
|
* exactly as the route's initialize request would.
|
|
167
183
|
*/
|
|
168
|
-
async function connectClient(mcp: McpService, tokenId: string | null): Promise<Client> {
|
|
184
|
+
async function connectClient(mcp: McpService, tokenId: string | null, connectionId: string | null = null): Promise<Client> {
|
|
169
185
|
const server = await mcp.createRequestServer(
|
|
170
|
-
{ userId: 'user-A', tokenId, bearer: 'bevel_testkey' },
|
|
186
|
+
{ userId: 'user-A', tokenId, connectionId, bearer: 'bevel_testkey' },
|
|
171
187
|
{ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test-client', version: '0.0.0' } } },
|
|
172
188
|
);
|
|
173
189
|
|
|
@@ -680,3 +696,104 @@ describe('McpService — a collision ends when its sibling is renamed away', ()
|
|
|
680
696
|
expect(survivor.some((m) => m.startsWith('[mcp] skipping manual "notion-eu": ') && !m.includes('rewrites to'))).toBe(true);
|
|
681
697
|
});
|
|
682
698
|
});
|
|
699
|
+
|
|
700
|
+
describe('McpService — the Audit log records what an agent calls', () => {
|
|
701
|
+
/** A recorder that keeps every event, and the same thing as the proxy's collaborator type. */
|
|
702
|
+
function spyRecorder(): IAgentEventRecorder & { events: AgentEventInput[] } {
|
|
703
|
+
const events: AgentEventInput[] = [];
|
|
704
|
+
return { events, record: (e) => void events.push(e) };
|
|
705
|
+
}
|
|
706
|
+
const text = (res: Awaited<ReturnType<Client['callTool']>>) => (res.content as Array<{ text: string }>)[0].text;
|
|
707
|
+
// Recording is fire-and-forget behind the call; give the microtasks a turn.
|
|
708
|
+
const settle = () => new Promise((r) => setTimeout(r, 10));
|
|
709
|
+
|
|
710
|
+
it('records a direct platform tool call as a hexis capability under the connection key, with its outcome', async () => {
|
|
711
|
+
const recorder = spyRecorder();
|
|
712
|
+
const client = await setup({ auditRecorder: recorder });
|
|
713
|
+
|
|
714
|
+
await client.callTool({ name: 'ask', arguments: { body: { prompt: 'hello' } } });
|
|
715
|
+
await client.callTool({ name: 'boom', arguments: {} });
|
|
716
|
+
await settle();
|
|
717
|
+
|
|
718
|
+
expect(recorder.events).toHaveLength(2);
|
|
719
|
+
expect(recorder.events[0]).toMatchObject({
|
|
720
|
+
userId: 'user-A',
|
|
721
|
+
principal: { kind: 'key', id: 'tok-1' },
|
|
722
|
+
kind: 'capability',
|
|
723
|
+
manual: null,
|
|
724
|
+
name: 'ask',
|
|
725
|
+
outcome: 'ok',
|
|
726
|
+
});
|
|
727
|
+
expect(typeof recorder.events[0].durationMs).toBe('number');
|
|
728
|
+
// The tool answered with an error result — recorded as such, not as ok.
|
|
729
|
+
expect(recorder.events[1]).toMatchObject({ kind: 'capability', name: 'boom', outcome: 'error' });
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('records a chain as a capability AND each call made inside it', async () => {
|
|
733
|
+
const recorder = spyRecorder();
|
|
734
|
+
const client = await setup({ auditRecorder: recorder });
|
|
735
|
+
|
|
736
|
+
await client.callTool({
|
|
737
|
+
name: 'call_tool_chain',
|
|
738
|
+
arguments: { code: "KNOWLEDGE_BASE.ask({ body: { prompt: 'one' } }); return KNOWLEDGE_BASE.ask({ body: { prompt: 'two' } });" },
|
|
739
|
+
});
|
|
740
|
+
await settle();
|
|
741
|
+
|
|
742
|
+
const names = recorder.events.map((e) => `${e.kind}:${e.name}:${e.outcome}`);
|
|
743
|
+
// The two inner calls finish (and are recorded) before the chain itself.
|
|
744
|
+
expect(names).toEqual(['capability:ask:ok', 'capability:ask:ok', 'capability:call_tool_chain:ok']);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('attributes an OAuth caller to its agent connection, and records nothing for a caller with neither', async () => {
|
|
748
|
+
const asAgent = spyRecorder();
|
|
749
|
+
const agent = await setup({ auditRecorder: asAgent, tokenId: null, connectionId: 'conn-1' });
|
|
750
|
+
await agent.callTool({ name: 'ask', arguments: { body: { prompt: 'hi' } } });
|
|
751
|
+
await settle();
|
|
752
|
+
expect(asAgent.events[0]).toMatchObject({ principal: { kind: 'agent', id: 'conn-1' }, name: 'ask' });
|
|
753
|
+
|
|
754
|
+
const nobody = spyRecorder();
|
|
755
|
+
const jwt = await setup({ auditRecorder: nobody, tokenId: null, connectionId: null });
|
|
756
|
+
await jwt.callTool({ name: 'ask', arguments: { body: { prompt: 'hi' } } });
|
|
757
|
+
await settle();
|
|
758
|
+
expect(nobody.events).toEqual([]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('records a read inside a skill folder as that skill, and a read elsewhere as the read', async () => {
|
|
762
|
+
const recorder = spyRecorder();
|
|
763
|
+
const client = await setup({ auditRecorder: recorder, extraTools: ['read_file', 'list_skills'] });
|
|
764
|
+
|
|
765
|
+
await client.callTool({ name: 'read_file', arguments: { body: { path: 'Plugins/Sales/rfi/SKILL.md' } } });
|
|
766
|
+
await client.callTool({ name: 'read_file', arguments: { body: { path: 'KnowledgeBase/Product/roadmap.md' } } });
|
|
767
|
+
await settle();
|
|
768
|
+
|
|
769
|
+
expect(recorder.events[0]).toMatchObject({ kind: 'skill', manual: 'Plugins/Sales/rfi', name: 'rfi', outcome: 'ok' });
|
|
770
|
+
expect(recorder.events[1]).toMatchObject({ kind: 'capability', manual: null, name: 'read_file', outcome: 'ok' });
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('records a prompt read as a skill, with an error outcome when the skill is unknown', async () => {
|
|
774
|
+
const recorder = spyRecorder();
|
|
775
|
+
const client = await setup({ auditRecorder: recorder });
|
|
776
|
+
// The harness serves no get_skill endpoint, so every prompt is unknown here.
|
|
777
|
+
await expect(client.getPrompt({ name: 'rfi' })).rejects.toThrow(/Unknown skill/);
|
|
778
|
+
await settle();
|
|
779
|
+
expect(recorder.events[0]).toMatchObject({ kind: 'skill', name: 'rfi', outcome: 'error' });
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('records a call refused for a missing sign-in as denied, without running the tool', async () => {
|
|
783
|
+
const recorder = spyRecorder();
|
|
784
|
+
const vault = { statusFor: async (_u: string, keys: string[]) => keys.map((key) => ({ key, adminConfigured: false, userConfigured: false })) };
|
|
785
|
+
const manuals = {
|
|
786
|
+
userScopedKeysForManual: async (manual: string) =>
|
|
787
|
+
manual === 'KNOWLEDGE_BASE' ? [{ key: 'KNOWLEDGE_BASE_API_KEY', name: 'API_KEY', label: 'Your API key', oauth: false }] : [],
|
|
788
|
+
};
|
|
789
|
+
const client = await setup({ auditRecorder: recorder, secretsVault: vault, toolManuals: manuals });
|
|
790
|
+
|
|
791
|
+
const res = await client.callTool({ name: 'ask', arguments: { body: { prompt: 'hello' } } });
|
|
792
|
+
await settle();
|
|
793
|
+
|
|
794
|
+
expect(text(res)).not.toMatch(/echo:/);
|
|
795
|
+
expect(recorder.events).toEqual([
|
|
796
|
+
expect.objectContaining({ kind: 'capability', name: 'ask', outcome: 'denied', durationMs: null }),
|
|
797
|
+
]);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
@@ -9,6 +9,7 @@ import type { InternalTokenService } from '../tool-auth/internal-token.service.j
|
|
|
9
9
|
import { rejectConnectionKey } from '../tool-auth/connection-key-rejection.js';
|
|
10
10
|
import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
|
|
11
11
|
import '../tool-auth/external-api-key.interface.js'; // Express Request augmentation (req.externalApiKeyId)
|
|
12
|
+
import '../audit/audit.contract.js'; // Express Request augmentation (req.agentConnectionId)
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Auth middleware for the MCP endpoint. Accepts any of:
|
|
@@ -111,6 +112,12 @@ export function createMcpAuthMiddleware(
|
|
|
111
112
|
const info = await oauthProvider.verifyAccessToken(token);
|
|
112
113
|
req.userId = String(info.extra?.userId ?? '');
|
|
113
114
|
req.userEmail = String(info.extra?.userEmail ?? '');
|
|
115
|
+
// The agent behind the grant, for the Audit log. A token minted
|
|
116
|
+
// before connections existed carries none; the call then runs
|
|
117
|
+
// unattributed rather than refused.
|
|
118
|
+
if (typeof info.extra?.connectionId === 'string') {
|
|
119
|
+
req.agentConnectionId = info.extra.connectionId;
|
|
120
|
+
}
|
|
114
121
|
if (!req.userId) {
|
|
115
122
|
unauthorized(res, 'Invalid access token');
|
|
116
123
|
return;
|
|
@@ -176,6 +183,30 @@ export function createMcpAuthMiddleware(
|
|
|
176
183
|
}
|
|
177
184
|
req.userId = user.id;
|
|
178
185
|
req.userEmail = user.email;
|
|
186
|
+
// The local server's exchanged grant names its agent connection (see
|
|
187
|
+
// InternalTokenClaim.connectionId). The token itself is stateless and
|
|
188
|
+
// lives up to five hours, so the connection is asked on every request
|
|
189
|
+
// whether it still admits the agent — revoking the agent must reach
|
|
190
|
+
// this path as surely as it reaches the grant's own tokens. The use is
|
|
191
|
+
// stamped too (throttled, off the request's path), so the Audit log's
|
|
192
|
+
// "last used" follows the local server's calls, not only its sign-ins.
|
|
193
|
+
// Carried on the request so those calls are logged under the agent.
|
|
194
|
+
if (claim.connectionId) {
|
|
195
|
+
let live: boolean;
|
|
196
|
+
try {
|
|
197
|
+
live = await oauthProvider.isConnectionLive(claim.connectionId);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log.error('agent-connection check failed:', { err });
|
|
200
|
+
res.status(500).json({ error: 'Authentication backend unavailable' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!live) {
|
|
204
|
+
unauthorized(res, 'Access for this agent was revoked');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
oauthProvider.noteConnectionUse(claim.connectionId);
|
|
208
|
+
req.agentConnectionId = claim.connectionId;
|
|
209
|
+
}
|
|
179
210
|
next();
|
|
180
211
|
return;
|
|
181
212
|
}
|
|
@@ -115,6 +115,10 @@ export function createMcpRoutes(
|
|
|
115
115
|
// undefined for OAuth/JWT) — resolved per request, so per-key metering
|
|
116
116
|
// never depends on anything remembered from an earlier request.
|
|
117
117
|
tokenId: req.externalApiKeyId ?? null,
|
|
118
|
+
// Agent-connection id (set for OAuth bearers and the local server's
|
|
119
|
+
// exchanged grant; undefined for keys/JWTs) — the Audit log's other
|
|
120
|
+
// principal, resolved per request like the key id.
|
|
121
|
+
connectionId: req.agentConnectionId ?? null,
|
|
118
122
|
// The proxy authenticates its loopback calls with the SAME bearer the
|
|
119
123
|
// client used here, so it acts on the request exactly as the caller would.
|
|
120
124
|
bearer: extractBearer(req),
|
|
@@ -355,7 +359,13 @@ export function createMcpRoutes(
|
|
|
355
359
|
grantRemainingMs === undefined
|
|
356
360
|
? MCP_LOOPBACK_TOKEN_TTL_MS
|
|
357
361
|
: Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
|
|
358
|
-
|
|
362
|
+
// The grant's agent connection rides along, so the local server's
|
|
363
|
+
// calls with this token stay attributed to the agent in the Audit log.
|
|
364
|
+
const connectionId = typeof info.extra?.connectionId === 'string' ? info.extra.connectionId : undefined;
|
|
365
|
+
const minted = internalTokens.mint(
|
|
366
|
+
{ userId, externalProxy: true, ...(connectionId ? { connectionId } : {}) },
|
|
367
|
+
ttlMs,
|
|
368
|
+
);
|
|
359
369
|
// The ACTUAL lifetime, not the constant — the caller schedules its
|
|
360
370
|
// proactive renewal off this number.
|
|
361
371
|
res.json({ token: minted, expiresInMs: ttlMs });
|
|
@@ -54,6 +54,8 @@ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.j
|
|
|
54
54
|
import type { SpillStore } from '../workspace/spill-store.js';
|
|
55
55
|
import { seedBevelHostedManualVars } from '../../shared/utcp-namespace.js';
|
|
56
56
|
import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
|
|
57
|
+
import type { IAgentEventRecorder } from '../audit/audit.contract.js';
|
|
58
|
+
import { RequestAudit } from '../audit/request-audit.js';
|
|
57
59
|
import { ManualFailureMemo } from './manual-failure-memo.js';
|
|
58
60
|
import { DownstreamPool, POOL_KEY_SEPARATOR, type DownstreamPoolOptions, type Lease } from './downstream-pool.js';
|
|
59
61
|
import { SurfaceLogThrottle } from './surface-log-throttle.js';
|
|
@@ -98,6 +100,12 @@ export interface McpCaller {
|
|
|
98
100
|
userId: string;
|
|
99
101
|
/** The connection-key id (per-key metering rides on it), or null for an OAuth/JWT bearer. */
|
|
100
102
|
tokenId: string | null;
|
|
103
|
+
/**
|
|
104
|
+
* The agent connection behind an OAuth bearer (or the local server's
|
|
105
|
+
* exchanged grant), or null. With `tokenId`, the two ways a call is
|
|
106
|
+
* attributed in the Audit log; a request with neither records nothing.
|
|
107
|
+
*/
|
|
108
|
+
connectionId?: string | null;
|
|
101
109
|
/** The raw bearer the request carried. */
|
|
102
110
|
bearer: string;
|
|
103
111
|
}
|
|
@@ -268,6 +276,10 @@ export class McpService {
|
|
|
268
276
|
// authorization when one of its TOOL sign-ins breaks. Optional: without
|
|
269
277
|
// it, broken sign-ins surface only as the /connect link in the result.
|
|
270
278
|
private readonly revokeOAuthAccess?: (bearer: string) => Promise<void>,
|
|
279
|
+
// The Audit log's recorder: every tool call, skill read and meta-tool run
|
|
280
|
+
// of a request that arrived with a key or an agent connection goes
|
|
281
|
+
// through it. Optional like the others; without it nothing is recorded.
|
|
282
|
+
private readonly auditRecorder?: IAgentEventRecorder,
|
|
271
283
|
) {
|
|
272
284
|
this.downstream = new DownstreamPool<PooledDownstream>({
|
|
273
285
|
...opts.downstreamPool,
|
|
@@ -332,6 +344,7 @@ export class McpService {
|
|
|
332
344
|
*/
|
|
333
345
|
async createRequestServer(caller: McpCaller, messages: unknown): Promise<Server> {
|
|
334
346
|
const { userId, tokenId, bearer } = caller;
|
|
347
|
+
const connectionId = caller.connectionId ?? null;
|
|
335
348
|
// The loopback surface (`/api/agent/*`) accepts connection keys and
|
|
336
349
|
// internal tokens only. A connection-key request passes the caller's own
|
|
337
350
|
// key through (per-key metering rides on it); an OAuth/JWT request's
|
|
@@ -347,8 +360,30 @@ export class McpService {
|
|
|
347
360
|
|
|
348
361
|
let instructions: Promise<ComposedAgentInstructions> | undefined;
|
|
349
362
|
const agentInstructions = () => (instructions ??= this.composeAgentInstructions());
|
|
363
|
+
// The Audit log's view of this request — only when the request can be
|
|
364
|
+
// attributed to a key or an agent connection. The catalog names it
|
|
365
|
+
// classifies pooled tools by are the surface's, which is built lazily
|
|
366
|
+
// below, so it reads them through a holder the surface fills in.
|
|
367
|
+
const catalogNamesHolder: { current: ReadonlyMap<string, string> } = { current: new Map() };
|
|
368
|
+
const principal = tokenId != null ? { kind: 'key' as const, id: tokenId } : connectionId ? { kind: 'agent' as const, id: connectionId } : null;
|
|
369
|
+
const audit =
|
|
370
|
+
this.auditRecorder && principal
|
|
371
|
+
? new RequestAudit(this.auditRecorder, userId, principal, {
|
|
372
|
+
kbManualName: EXTERNAL_KB_MANUAL_NAME,
|
|
373
|
+
catalogNames: () => catalogNamesHolder.current,
|
|
374
|
+
loadSkills: () =>
|
|
375
|
+
this.fetchSkillList(loopbackBearer).then((skills) =>
|
|
376
|
+
skills.flatMap((s) => (s.path ? [{ name: s.name, path: s.path }] : [])),
|
|
377
|
+
),
|
|
378
|
+
})
|
|
379
|
+
: null;
|
|
350
380
|
let surface: Promise<RequestSurface> | undefined;
|
|
351
|
-
const requestSurface = () =>
|
|
381
|
+
const requestSurface = () =>
|
|
382
|
+
(surface ??= this.buildSurface(userId, tokenId, loopbackBearer).then((built) => {
|
|
383
|
+
catalogNamesHolder.current = built.catalogNames;
|
|
384
|
+
audit?.instrumentChainCalls(built.client);
|
|
385
|
+
return built;
|
|
386
|
+
}));
|
|
352
387
|
|
|
353
388
|
const initializing = (Array.isArray(messages) ? messages : [messages]).some((m) => isInitializeRequest(m));
|
|
354
389
|
const server = new Server(
|
|
@@ -432,8 +467,12 @@ export class McpService {
|
|
|
432
467
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
433
468
|
const { client, tools, unavailable, catalogNames } = await requestSurface();
|
|
434
469
|
const toolName = request.params.name;
|
|
470
|
+
const args = request.params.arguments ?? {};
|
|
435
471
|
if (META_TOOL_NAMES.has(toolName)) {
|
|
436
|
-
|
|
472
|
+
// A meta-tool is the platform's own; its inner tool calls (a chain's)
|
|
473
|
+
// are recorded one by one through the instrumented client.
|
|
474
|
+
const run = () => this.dispatchMetaTool(client, toolName, args);
|
|
475
|
+
return audit ? audit.call(toolName, args, run, isErrorResult) : run();
|
|
437
476
|
}
|
|
438
477
|
/**
|
|
439
478
|
* The needs-authorization answer, when the caller's sign-in for the
|
|
@@ -476,11 +515,18 @@ export class McpService {
|
|
|
476
515
|
.filter((m) => toolName.startsWith(`${m.utcpName}_`))
|
|
477
516
|
.sort((a, b) => b.utcpName.length - a.utcpName.length)[0];
|
|
478
517
|
const answer = owner ? await needsAuthorization(owner.utcpName) : null;
|
|
518
|
+
if (answer && owner && audit) {
|
|
519
|
+
await audit.denied(`${owner.utcpName}.${toolName.slice(owner.utcpName.length + 1)}`, args);
|
|
520
|
+
}
|
|
479
521
|
return answer ?? toolError(retiredToolMessage(toolName) ?? `Unknown tool "${toolName}".`);
|
|
480
522
|
}
|
|
481
523
|
const needsAuth = await needsAuthorization(proxied.manualName);
|
|
482
|
-
if (needsAuth)
|
|
483
|
-
|
|
524
|
+
if (needsAuth) {
|
|
525
|
+
if (audit) await audit.denied(proxied.utcpName, args);
|
|
526
|
+
return needsAuth;
|
|
527
|
+
}
|
|
528
|
+
const run = () => this.dispatch(client, proxied, request, extra);
|
|
529
|
+
return audit ? audit.call(proxied.utcpName, args, run, isErrorResult) : run();
|
|
484
530
|
});
|
|
485
531
|
|
|
486
532
|
// Prompts = skills. Each skill becomes a user-callable prompt (slash command
|
|
@@ -499,7 +545,14 @@ export class McpService {
|
|
|
499
545
|
});
|
|
500
546
|
|
|
501
547
|
server.setRequestHandler(GetPromptRequestSchema, async (request): Promise<GetPromptResult> => {
|
|
548
|
+
const started = performance.now();
|
|
502
549
|
const skill = await this.fetchSkill(loopbackBearer, request.params.name);
|
|
550
|
+
// A prompt IS a skill read — the same event `get_skill` produces.
|
|
551
|
+
audit?.emit(
|
|
552
|
+
{ kind: 'skill', manual: skill?.path ?? null, name: request.params.name },
|
|
553
|
+
skill ? 'ok' : 'error',
|
|
554
|
+
Math.round(performance.now() - started),
|
|
555
|
+
);
|
|
503
556
|
if (!skill) {
|
|
504
557
|
throw new McpError(ErrorCode.InvalidParams, `Unknown skill "${request.params.name}".`);
|
|
505
558
|
}
|
|
@@ -1176,6 +1229,11 @@ export class McpService {
|
|
|
1176
1229
|
}
|
|
1177
1230
|
}
|
|
1178
1231
|
|
|
1232
|
+
/** Whether a tool result is the tool saying it failed — what the Audit log records as `error`. */
|
|
1233
|
+
function isErrorResult(result: CallToolResult): boolean {
|
|
1234
|
+
return result.isError === true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1179
1237
|
/**
|
|
1180
1238
|
* The UTCP client config for `userId`: the given variables, plus the per-user
|
|
1181
1239
|
* `bevel-secrets` loader that resolves every `${SECRET}` ref lazily. Shared by
|