@bevel-software/platform-core-backend 0.13.3 → 0.13.6
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/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +4 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +10 -0
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +11 -0
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.js +18 -3
- package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +26 -4
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/package.json +4 -4
- package/src/core/create-core-server.ts +4 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +4 -1
- package/src/modules/kb-fs/clone-config.ts +11 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +54 -1
- package/src/modules/workspace/file-readers/extract-pdf.ts +18 -4
- package/src/modules/workspace/workspace.tools.ts +22 -4
|
@@ -1,398 +1,482 @@
|
|
|
1
|
-
import express, { type Request, type RequestHandler } from 'express';
|
|
2
|
-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
4
|
-
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
-
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
-
import {
|
|
7
|
-
InvalidTokenLabelError,
|
|
8
|
-
TokenNotFoundError,
|
|
9
|
-
TokenStillActiveError,
|
|
10
|
-
} from '../tool-auth/external-api-key.errors.js';
|
|
11
|
-
import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
|
|
12
|
-
import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
|
|
13
|
-
import { MCP_LOOPBACK_TOKEN_TTL_MS, type McpService } from './mcp.service.js';
|
|
14
|
-
import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
|
|
15
|
-
import type { ILlmUsageMeter } from '../tool-auth/llm-usage-meter.js';
|
|
16
|
-
import '../tool-auth/external-api-key.interface.js'; // req.externalApiKeyId augmentation
|
|
17
|
-
|
|
18
|
-
interface ActiveSession {
|
|
19
|
-
transport: StreamableHTTPServerTransport;
|
|
20
|
-
server: Server;
|
|
21
|
-
userId: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Pull the raw bearer token off an already-authenticated request. */
|
|
25
|
-
function extractBearer(req: Request): string {
|
|
26
|
-
const header = req.headers.authorization ?? '';
|
|
27
|
-
const firstSpace = header.indexOf(' ');
|
|
28
|
-
return firstSpace >= 0 ? header.slice(firstSpace + 1).trim() : '';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
res
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
res
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
res.
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1
|
+
import express, { type Request, type Response, type RequestHandler } from 'express';
|
|
2
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
4
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import {
|
|
7
|
+
InvalidTokenLabelError,
|
|
8
|
+
TokenNotFoundError,
|
|
9
|
+
TokenStillActiveError,
|
|
10
|
+
} from '../tool-auth/external-api-key.errors.js';
|
|
11
|
+
import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
|
|
12
|
+
import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
|
|
13
|
+
import { MCP_LOOPBACK_TOKEN_TTL_MS, type McpService } from './mcp.service.js';
|
|
14
|
+
import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
|
|
15
|
+
import type { ILlmUsageMeter } from '../tool-auth/llm-usage-meter.js';
|
|
16
|
+
import '../tool-auth/external-api-key.interface.js'; // req.externalApiKeyId augmentation
|
|
17
|
+
|
|
18
|
+
interface ActiveSession {
|
|
19
|
+
transport: StreamableHTTPServerTransport;
|
|
20
|
+
server: Server;
|
|
21
|
+
userId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pull the raw bearer token off an already-authenticated request. */
|
|
25
|
+
function extractBearer(req: Request): string {
|
|
26
|
+
const header = req.headers.authorization ?? '';
|
|
27
|
+
const firstSpace = header.indexOf(' ');
|
|
28
|
+
return firstSpace >= 0 ? header.slice(firstSpace + 1).trim() : '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The two JSON-RPC codes the Streamable HTTP transport defines for a session
|
|
33
|
+
* fault, paired with the HTTP status each rides on.
|
|
34
|
+
*
|
|
35
|
+
* `SESSION_NOT_FOUND` (404) is the one that carries meaning to a client: the
|
|
36
|
+
* spec makes a 404 on a request bearing an `Mcp-Session-Id` the trigger to
|
|
37
|
+
* start a new session with a fresh `initialize`. `BAD_REQUEST` (400) is the
|
|
38
|
+
* client-side mistake — a non-initialize request that never carried a session
|
|
39
|
+
* id at all — and has no recovery, because there is nothing to recover.
|
|
40
|
+
*/
|
|
41
|
+
const SESSION_NOT_FOUND = -32001;
|
|
42
|
+
const BAD_REQUEST = -32000;
|
|
43
|
+
/**
|
|
44
|
+
* The 403: a session id that exists but belongs to someone else. Its own code,
|
|
45
|
+
* in the same implementation-defined range (-32000..-32099), because it is
|
|
46
|
+
* neither of the two above — the request was well-formed and the session is
|
|
47
|
+
* live; the caller simply may not have it. A client that reads only
|
|
48
|
+
* `error.code` must not mistake an authorization refusal for a malformed
|
|
49
|
+
* request, and must not re-initialize on it either. -32002 is skipped: the
|
|
50
|
+
* MCP SDK spends it on "resource not found".
|
|
51
|
+
*/
|
|
52
|
+
const FORBIDDEN = -32003;
|
|
53
|
+
/** JSON-RPC 2.0's own code for a fault on our side, used for the 500 catch-alls. */
|
|
54
|
+
const INTERNAL_ERROR = -32603;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Answer in the transport's own wire shape
|
|
58
|
+
* (`{ jsonrpc, error: { code, message }, id: null }`).
|
|
59
|
+
*
|
|
60
|
+
* These misses are caught by THIS router, one layer before the request would
|
|
61
|
+
* have reached an SDK transport, so the router has to speak the transport's
|
|
62
|
+
* language itself — a client that parses the body as JSON-RPC must not get a
|
|
63
|
+
* bare `{ error }` blob just because we answered early. `id: null` matches the
|
|
64
|
+
* SDK: the request id is not reliably known on a body we may never have
|
|
65
|
+
* parsed as a single message.
|
|
66
|
+
*/
|
|
67
|
+
function jsonRpcError(res: Response, status: number, code: number, message: string): void {
|
|
68
|
+
res.status(status).json({ jsonrpc: '2.0', error: { code, message }, id: null });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The answer to a session this router cannot resolve, shared by all three verbs.
|
|
73
|
+
*
|
|
74
|
+
* POST and GET/DELETE reach the miss from opposite directions — POST falls
|
|
75
|
+
* through to it, GET/DELETE test for it up front — but owe the caller the same
|
|
76
|
+
* two answers, and they were hand-synchronised copies of the same pair of
|
|
77
|
+
* codes and strings. Sharing the mechanism makes that parity structural
|
|
78
|
+
* instead of merely asserted by tests.
|
|
79
|
+
*
|
|
80
|
+
* Call only once the session is known to be a miss; a live session id never
|
|
81
|
+
* reaches here.
|
|
82
|
+
*/
|
|
83
|
+
function rejectSessionMiss(res: Response, sessionIdHeader: string | undefined): void {
|
|
84
|
+
if (sessionIdHeader) {
|
|
85
|
+
// A session id we hold no transport for: the store evicted it, or the
|
|
86
|
+
// process restarted and this in-memory map went with it. 404 + `-32001`
|
|
87
|
+
// is what the spec reserves for that, and it is the signal a client keys
|
|
88
|
+
// its re-initialize on. Answering 400 here read as "you sent a malformed
|
|
89
|
+
// request" and stranded the client on a session that can never come
|
|
90
|
+
// back — every connected agent, on every restart, until a human
|
|
91
|
+
// reconnected it by hand.
|
|
92
|
+
jsonRpcError(res, 404, SESSION_NOT_FOUND, 'Session not found');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// No session id at all: the one case here that genuinely is the caller's
|
|
96
|
+
// mistake, and the only one 400 fits.
|
|
97
|
+
jsonRpcError(res, 400, BAD_REQUEST, 'Bad Request: Mcp-Session-Id header is required');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Routes for the remote MCP server + the connection-key management endpoints.
|
|
102
|
+
*
|
|
103
|
+
* Layout:
|
|
104
|
+
* POST /mcp — client→server MCP messages (initialize + tool calls)
|
|
105
|
+
* GET /mcp — server→client SSE channel (session-bound)
|
|
106
|
+
* DELETE /mcp — terminate a session
|
|
107
|
+
*
|
|
108
|
+
* GET /mcp/external-api-keys — list this user's connection keys
|
|
109
|
+
* POST /mcp/external-api-keys — mint a new key (returns plaintext ONCE)
|
|
110
|
+
* DELETE /mcp/external-api-keys/:id — revoke
|
|
111
|
+
*
|
|
112
|
+
* POST /mcp/local-token — exchange an MCP OAuth access token
|
|
113
|
+
* for a loopback internal token
|
|
114
|
+
*
|
|
115
|
+
* The `/mcp` endpoints accept either a connection key or a JWT (see
|
|
116
|
+
* McpAuthMiddleware). The `/mcp/external-api-keys/*` endpoints accept only the JWT —
|
|
117
|
+
* minting/revoking via a connection key would let a leaked key roll itself
|
|
118
|
+
* over and stay alive forever. `/mcp/local-token` accepts ONLY an MCP OAuth
|
|
119
|
+
* access token — every other credential already opens the surface it bridges to.
|
|
120
|
+
*/
|
|
121
|
+
export function createMcpRoutes(
|
|
122
|
+
mcpService: McpService,
|
|
123
|
+
externalApiKeyService: IExternalApiKeyService,
|
|
124
|
+
mcpAuthMiddleware: RequestHandler,
|
|
125
|
+
jwtAuthMiddleware: RequestHandler,
|
|
126
|
+
llmUsageService: ILlmUsageMeter,
|
|
127
|
+
internalTokens: InternalTokenService,
|
|
128
|
+
oauthProvider: BevelOAuthProvider,
|
|
129
|
+
// RFC 9728 pointer carried on this router's own 401 challenges (the
|
|
130
|
+
// local-token exchange), same value McpAuthMiddleware advertises.
|
|
131
|
+
resourceMetadataUrl: string,
|
|
132
|
+
): express.Router {
|
|
133
|
+
const router = express.Router();
|
|
134
|
+
|
|
135
|
+
// Per-process map of live MCP sessions. In-memory mirror of McpSessionStore
|
|
136
|
+
// — McpSessionStore holds the *domain* state (userId, threadId, idle TTL),
|
|
137
|
+
// this map holds the *transport* state (the SDK objects we need to route
|
|
138
|
+
// a subsequent HTTP request to the correct session). Both are cleared on
|
|
139
|
+
// restart; clients re-initialize.
|
|
140
|
+
const active = new Map<string, ActiveSession>();
|
|
141
|
+
|
|
142
|
+
// When McpSessionStore drops a session on its own (idle-TTL sweep or
|
|
143
|
+
// size-cap eviction), the matching transport pair would otherwise leak
|
|
144
|
+
// here — `active.has(id)` would still return true and route requests to a
|
|
145
|
+
// McpServer whose backing domain state is gone. Subscribe to the store and
|
|
146
|
+
// tear down the transport so the resources free immediately and the next
|
|
147
|
+
// request with that id correctly 404s.
|
|
148
|
+
mcpService.onSessionEvicted((sessionId) => {
|
|
149
|
+
const entry = active.get(sessionId);
|
|
150
|
+
if (!entry) return;
|
|
151
|
+
active.delete(sessionId);
|
|
152
|
+
// close() is async; fire-and-forget so a slow socket teardown can't
|
|
153
|
+
// block the store's eviction loop. transport.onclose still fires but
|
|
154
|
+
// its `active.delete` is now a no-op.
|
|
155
|
+
void entry.transport.close().catch((err) => {
|
|
156
|
+
console.warn('[mcp] transport close on eviction failed:', err);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── MCP transport ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
router.post('/mcp', mcpAuthMiddleware, async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
const sessionIdHeader = req.headers['mcp-session-id'] as string | undefined;
|
|
165
|
+
const body = req.body;
|
|
166
|
+
|
|
167
|
+
// Four request shapes on POST /mcp, in this order:
|
|
168
|
+
// 1. Session id naming a live session → forward to its transport
|
|
169
|
+
// (unless it is someone else's → 403 (`-32003`)).
|
|
170
|
+
// 2. An initialize body → spin up a new transport, whatever stale
|
|
171
|
+
// session id the client still has attached.
|
|
172
|
+
// 3. A session id we have no transport for → 404 (`-32001`).
|
|
173
|
+
// 4. No session id on a non-initialize request → 400 (`-32000`).
|
|
174
|
+
if (sessionIdHeader && active.has(sessionIdHeader)) {
|
|
175
|
+
const session = active.get(sessionIdHeader)!;
|
|
176
|
+
// Defense in depth: the auth middleware bound a userId for this
|
|
177
|
+
// request; refuse if it doesn't match the session's owner. Stops
|
|
178
|
+
// a leaked session id from being used cross-user even if the
|
|
179
|
+
// attacker has their own valid connection key.
|
|
180
|
+
if (session.userId !== req.userId) {
|
|
181
|
+
jsonRpcError(res, 403, FORBIDDEN, 'Session does not belong to this user');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await session.transport.handleRequest(req, res, body);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// An initialize starts a fresh session even when the client is STILL
|
|
189
|
+
// sending a stale `mcp-session-id`. Requiring the header to be absent
|
|
190
|
+
// deadlocked exactly the client this endpoint most needs to let back in:
|
|
191
|
+
// one whose session died with the server and that re-initializes without
|
|
192
|
+
// first clearing the id — it got the catch-all below forever. The SDK's
|
|
193
|
+
// own transport orders the two checks this way for the same reason:
|
|
194
|
+
// initialize is never session-validated. A LIVE session id is still
|
|
195
|
+
// caught by the branch above, so re-initializing over a working session
|
|
196
|
+
// stays the SDK's `-32600 Server already initialized`, not a silent
|
|
197
|
+
// second session.
|
|
198
|
+
if (isInitializeRequest(body)) {
|
|
199
|
+
// The proxy authenticates its loopback calls with the SAME bearer the
|
|
200
|
+
// client used here, so it acts on the request exactly as the caller
|
|
201
|
+
// would. Captured at initialize and seeded into the session's UtcpClient.
|
|
202
|
+
const { transport, server } = await mcpService.createSession(
|
|
203
|
+
req.userId!,
|
|
204
|
+
// Connection-key id (set by mcpAuthMiddleware for `bevel_…` bearers;
|
|
205
|
+
// undefined for browser JWT) — kept for audit/diagnostics.
|
|
206
|
+
req.externalApiKeyId ?? null,
|
|
207
|
+
extractBearer(req),
|
|
208
|
+
(sessionId) => {
|
|
209
|
+
active.set(sessionId, { transport, server, userId: req.userId! });
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
// Closing the transport (DELETE /mcp, or client disconnect during
|
|
213
|
+
// close) should drop both the active map and the McpSessionStore
|
|
214
|
+
// entry. McpService wired onsessionclosed → sessionStore.delete; we
|
|
215
|
+
// mirror it here.
|
|
216
|
+
transport.onclose = () => {
|
|
217
|
+
if (transport.sessionId) active.delete(transport.sessionId);
|
|
218
|
+
};
|
|
219
|
+
await server.connect(transport);
|
|
220
|
+
await transport.handleRequest(req, res, body);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Neither a live session nor an initialize: whichever miss this is,
|
|
225
|
+
// `rejectSessionMiss` owns both answers.
|
|
226
|
+
rejectSessionMiss(res, sessionIdHeader);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
229
|
+
console.error('[mcp] POST /mcp failed:', msg);
|
|
230
|
+
if (!res.headersSent) {
|
|
231
|
+
jsonRpcError(res, 500, INTERNAL_ERROR, msg);
|
|
232
|
+
} else {
|
|
233
|
+
res.end();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const sessionRequest: RequestHandler = async (req, res) => {
|
|
239
|
+
const sessionIdHeader = req.headers['mcp-session-id'] as string | undefined;
|
|
240
|
+
// The same two answers POST gives, from the same helper: these were
|
|
241
|
+
// collapsed into a single 404 — right for the unknown-session case, wrong
|
|
242
|
+
// for the missing-header one, and neither parseable as JSON-RPC.
|
|
243
|
+
if (!sessionIdHeader || !active.has(sessionIdHeader)) {
|
|
244
|
+
rejectSessionMiss(res, sessionIdHeader);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const session = active.get(sessionIdHeader)!;
|
|
248
|
+
if (session.userId !== req.userId) {
|
|
249
|
+
jsonRpcError(res, 403, FORBIDDEN, 'Session does not belong to this user');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await session.transport.handleRequest(req, res);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
256
|
+
console.error('[mcp] session request failed:', msg);
|
|
257
|
+
if (!res.headersSent) jsonRpcError(res, 500, INTERNAL_ERROR, msg);
|
|
258
|
+
else res.end();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// GET is the server→client SSE channel for session-scoped notifications.
|
|
263
|
+
router.get('/mcp', mcpAuthMiddleware, sessionRequest);
|
|
264
|
+
|
|
265
|
+
// DELETE terminates the session — SDK closes the transport, our
|
|
266
|
+
// onclose handler cleans up the active map, and McpService's
|
|
267
|
+
// onsessionclosed clears the McpSessionStore entry.
|
|
268
|
+
router.delete('/mcp', mcpAuthMiddleware, sessionRequest);
|
|
269
|
+
|
|
270
|
+
// ── Connection-key management (JWT-only) ───────────────────────────────
|
|
271
|
+
|
|
272
|
+
router.get('/mcp/external-api-keys', jwtAuthMiddleware, async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const tokens = await externalApiKeyService.listForUser(req.userId!);
|
|
275
|
+
// Enrich each key with its LLM-proxy usage today + the daily cap, so the
|
|
276
|
+
// settings UI can show how much of the model budget the key has spent.
|
|
277
|
+
const usage = await llmUsageService.usageForTokens(tokens.map((t) => t.id));
|
|
278
|
+
res.json(
|
|
279
|
+
tokens.map((t) => ({
|
|
280
|
+
...t,
|
|
281
|
+
llmUsage: {
|
|
282
|
+
usedTodayTokens: usage[t.id] ?? 0,
|
|
283
|
+
dailyTokenCap: llmUsageService.dailyCap,
|
|
284
|
+
},
|
|
285
|
+
})),
|
|
286
|
+
);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
289
|
+
res.status(500).json({ error: msg });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
router.post('/mcp/external-api-keys', jwtAuthMiddleware, async (req, res) => {
|
|
294
|
+
try {
|
|
295
|
+
// `express.json()` leaves req.body undefined when the client posts
|
|
296
|
+
// without `Content-Type: application/json` (or with another type
|
|
297
|
+
// entirely). Destructuring undefined throws a TypeError that the outer
|
|
298
|
+
// catch would 500 — guard with a clean 400 instead.
|
|
299
|
+
if (!req.body || typeof req.body !== 'object') {
|
|
300
|
+
res.status(400).json({ error: 'JSON body required' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const { label } = req.body as { label?: string };
|
|
304
|
+
if (!label) {
|
|
305
|
+
res.status(400).json({ error: 'label is required' });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const minted = await externalApiKeyService.mint(req.userId!, label);
|
|
309
|
+
// The plaintext field is the *only* read path for the raw key. The
|
|
310
|
+
// frontend must store it nowhere — it shows the dialog once and then
|
|
311
|
+
// discards. Subsequent fetches return only `summary`-shaped rows.
|
|
312
|
+
res.json(minted);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err instanceof InvalidTokenLabelError) {
|
|
315
|
+
res.status(400).json({ error: err.message });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
319
|
+
res.status(500).json({ error: msg });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
router.delete('/mcp/external-api-keys/:id', jwtAuthMiddleware, async (req, res) => {
|
|
324
|
+
try {
|
|
325
|
+
await externalApiKeyService.revoke(String(req.params.id), req.userId!);
|
|
326
|
+
res.json({ status: 'revoked' });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err instanceof TokenNotFoundError) {
|
|
329
|
+
res.status(404).json({ error: err.message });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
333
|
+
res.status(500).json({ error: msg });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Hard-delete: permanently remove a *disconnected* key and its audit row.
|
|
338
|
+
// Separate path from revoke so the two lifecycle steps can't be conflated;
|
|
339
|
+
// the service refuses to delete a still-active key (409).
|
|
340
|
+
router.delete('/mcp/external-api-keys/:id/permanent', jwtAuthMiddleware, async (req, res) => {
|
|
341
|
+
try {
|
|
342
|
+
await externalApiKeyService.remove(String(req.params.id), req.userId!);
|
|
343
|
+
res.json({ status: 'deleted' });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err instanceof TokenNotFoundError) {
|
|
346
|
+
res.status(404).json({ error: err.message });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (err instanceof TokenStillActiveError) {
|
|
350
|
+
res.status(409).json({ error: err.message });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
354
|
+
res.status(500).json({ error: msg });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ── Local-server token exchange (OAuth-access-token-only) ──────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Exchange an MCP OAuth access token for a short-lived internal token.
|
|
362
|
+
*
|
|
363
|
+
* Why it exists: the LOCAL MCP server's REST reads — the all-tools manual,
|
|
364
|
+
* `list_local_tools`, the plugin archive — live on `/api/agent/*`, which
|
|
365
|
+
* accepts connection keys and internal tokens ONLY; an MCP OAuth access
|
|
366
|
+
* token deliberately 401s there. Hosted OAuth sessions cross that gap
|
|
367
|
+
* inside `McpService.createSession`, which mints a loopback internal token
|
|
368
|
+
* for the resolved user. This endpoint is the same exchange for an external
|
|
369
|
+
* caller: the one bridge that lets a local server configured via the
|
|
370
|
+
* deployment's MCP OAuth (instead of a connection key) reach those reads.
|
|
371
|
+
*
|
|
372
|
+
* Why it is NOT a widening of the trust boundary: the caller must present a
|
|
373
|
+
* VERIFIED OAuth grant for this exact user — the same credential that
|
|
374
|
+
* already drives full tool execution through the hosted `/mcp` endpoint.
|
|
375
|
+
* The minted token is identical in shape to createSession's loopback bearer
|
|
376
|
+
* (`{ userId, externalProxy: true }` → resolved as `source: 'external'` by
|
|
377
|
+
* the tool-auth verifier, admitted to the external surface, refused from
|
|
378
|
+
* internal-only tools) and carries the same TTL — CAPPED to the presented
|
|
379
|
+
* access token's remaining lifetime, so the exchange can never mint a
|
|
380
|
+
* credential that outlives its grant. Nothing becomes reachable that the
|
|
381
|
+
* grant did not already reach — only the credential's spelling changes.
|
|
382
|
+
*
|
|
383
|
+
* Auth semantics mirror McpAuthMiddleware's OAuth branch: an
|
|
384
|
+
* invalid/expired/revoked token is a 401 re-challenging with
|
|
385
|
+
* `resource_metadata` (RFC 9728) so the client can re-authorize; a backend
|
|
386
|
+
* failure during verification is a 500. A connection key, internal token,
|
|
387
|
+
* or JWT is a 403 — those credentials need no exchange, so accepting them
|
|
388
|
+
* here would only manufacture a second credential from a first.
|
|
389
|
+
*
|
|
390
|
+
* Response: `{ token, expiresInMs }`.
|
|
391
|
+
*/
|
|
392
|
+
router.post('/mcp/local-token', async (req, res) => {
|
|
393
|
+
const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
|
|
394
|
+
const unauthorized = (error: string) => {
|
|
395
|
+
res.setHeader('WWW-Authenticate', wwwAuthenticate);
|
|
396
|
+
res.status(401).json({ error });
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const header = req.headers.authorization;
|
|
400
|
+
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
|
401
|
+
unauthorized('Missing or invalid Authorization header');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const token = extractBearer(req);
|
|
405
|
+
|
|
406
|
+
if (!oauthProvider.looksLikeAccessToken(token)) {
|
|
407
|
+
// A recognizable non-OAuth credential gets an explicit 403: a
|
|
408
|
+
// connection key or internal token already opens `/api/agent/*`
|
|
409
|
+
// directly, and a JWT holder mints a connection key from the settings
|
|
410
|
+
// UI — none of them has anything to exchange.
|
|
411
|
+
if (
|
|
412
|
+
externalApiKeyService.looksLikeExternalApiKey(token) ||
|
|
413
|
+
internalTokens.looksLikeInternalToken(token) ||
|
|
414
|
+
token.startsWith('eyJ')
|
|
415
|
+
) {
|
|
416
|
+
res.status(403).json({
|
|
417
|
+
error:
|
|
418
|
+
'This endpoint exchanges MCP OAuth access tokens only. Connection keys, ' +
|
|
419
|
+
'internal tokens, and JWTs need no exchange — use them directly.',
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// Unrecognizable bearer — re-challenge so an OAuth-capable client can
|
|
424
|
+
// discover the authorization server and obtain a real access token.
|
|
425
|
+
unauthorized('Invalid access token');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const info = await oauthProvider.verifyAccessToken(token);
|
|
431
|
+
const userId = String(info.extra?.userId ?? '');
|
|
432
|
+
if (!userId) {
|
|
433
|
+
unauthorized('Invalid access token');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// The minted token must never OUTLIVE the grant that authorized it: an
|
|
437
|
+
// OAuth access token revoked-by-expiry would otherwise leave a live
|
|
438
|
+
// internal token behind for the rest of the loopback TTL. Bind the TTL
|
|
439
|
+
// to whichever ends first — the constant, or the access token's own
|
|
440
|
+
// remaining lifetime (AuthInfo.expiresAt is epoch SECONDS, optional; a
|
|
441
|
+
// provider that reports none falls back to the constant alone).
|
|
442
|
+
const grantRemainingMs =
|
|
443
|
+
typeof info.expiresAt === 'number' ? info.expiresAt * 1000 - Date.now() : undefined;
|
|
444
|
+
// A grant with no life left mints NOTHING: a 200 carrying an
|
|
445
|
+
// already-dead token would read as success to the caller, whose first
|
|
446
|
+
// real request then fails somewhere far from the cause. It is the same
|
|
447
|
+
// 401 an expired token gets from the verifier, challenge and all — and
|
|
448
|
+
// a non-finite expiresAt (a provider handing back garbage) is refused
|
|
449
|
+
// the same way rather than turned into a TTL. Deliberately STRICTER
|
|
450
|
+
// than the verifier at the boundary: AuthInfo floors the expiry to
|
|
451
|
+
// whole seconds, so a grant inside its final partial second computes
|
|
452
|
+
// as spent here even though the verifier (which compares the stored
|
|
453
|
+
// millisecond timestamp) just accepted it — but that sub-second
|
|
454
|
+
// remainder could only mint a token that is dead before its first use,
|
|
455
|
+
// and refusing it is exactly this guard's job.
|
|
456
|
+
if (grantRemainingMs !== undefined && !(Number.isFinite(grantRemainingMs) && grantRemainingMs > 0)) {
|
|
457
|
+
unauthorized('Invalid, expired, or revoked access token');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const ttlMs =
|
|
461
|
+
grantRemainingMs === undefined
|
|
462
|
+
? MCP_LOOPBACK_TOKEN_TTL_MS
|
|
463
|
+
: Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
|
|
464
|
+
const minted = internalTokens.mint({ userId, externalProxy: true }, ttlMs);
|
|
465
|
+
// The ACTUAL lifetime, not the constant — the caller schedules its
|
|
466
|
+
// proactive renewal off this number.
|
|
467
|
+
res.json({ token: minted, expiresInMs: ttlMs });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
// Same split as McpAuthMiddleware: a bad token is a clean 401 with the
|
|
470
|
+
// discovery challenge; a backend failure is a 500 — the credential may
|
|
471
|
+
// be fine, we just can't check it right now.
|
|
472
|
+
if (err instanceof InvalidTokenError) {
|
|
473
|
+
unauthorized('Invalid, expired, or revoked access token');
|
|
474
|
+
} else {
|
|
475
|
+
console.error('[mcp] local-token exchange failed:', err);
|
|
476
|
+
res.status(500).json({ error: 'Authentication backend unavailable' });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return router;
|
|
482
|
+
}
|