@bevel-software/platform-core-backend 0.13.5 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/THIRD-PARTY-NOTICES.md +639 -433
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +6 -3
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +2 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +9 -0
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts.map +1 -1
  10. package/dist/core-config.js +12 -5
  11. package/dist/core-config.js.map +1 -1
  12. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  13. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  14. package/dist/modules/access-model/kb-read-filter.js +15 -0
  15. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  19. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  21. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  23. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  24. package/dist/modules/connection-probe/index.d.ts +3 -0
  25. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  26. package/dist/modules/connection-probe/index.js +3 -0
  27. package/dist/modules/connection-probe/index.js.map +1 -0
  28. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  29. package/dist/modules/diff/diff.routes.js +3 -5
  30. package/dist/modules/diff/diff.routes.js.map +1 -1
  31. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  32. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  33. package/dist/modules/kb-fs/mutex.js +48 -5
  34. package/dist/modules/kb-fs/mutex.js.map +1 -1
  35. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  36. package/dist/modules/mcp/mcp.routes.js +95 -13
  37. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  38. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  39. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  40. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  41. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  42. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  43. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  45. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  46. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  47. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  49. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  51. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  52. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  53. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  54. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  55. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  56. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  57. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  58. package/dist/modules/workflow/git/git.service.js +98 -6
  59. package/dist/modules/workflow/git/git.service.js.map +1 -1
  60. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  61. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.routes.js +98 -9
  63. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  64. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  65. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  66. package/dist/modules/workflow/workflow.service.js +220 -43
  67. package/dist/modules/workflow/workflow.service.js.map +1 -1
  68. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.routes.js +2 -5
  70. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  71. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  72. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  73. package/dist/modules/workspace/workspace.service.js +21 -4
  74. package/dist/modules/workspace/workspace.service.js.map +1 -1
  75. package/dist/shared/token-crypto.d.ts +17 -2
  76. package/dist/shared/token-crypto.d.ts.map +1 -1
  77. package/dist/shared/token-crypto.js +25 -8
  78. package/dist/shared/token-crypto.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/__tests__/core-config.admin.test.ts +21 -0
  81. package/src/core/create-core-server.ts +7 -2
  82. package/src/core/create-core-services.ts +11 -0
  83. package/src/core-config.ts +14 -7
  84. package/src/modules/access-model/kb-read-filter.ts +22 -0
  85. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  86. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  87. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  88. package/src/modules/connection-probe/index.ts +2 -0
  89. package/src/modules/diff/diff.routes.ts +9 -5
  90. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  91. package/src/modules/kb-fs/mutex.ts +50 -5
  92. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
  93. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
  94. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
  95. package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
  96. package/src/modules/mcp/mcp.routes.ts +482 -398
  97. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  98. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  99. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  100. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  101. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  102. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  103. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  104. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  105. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  106. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  107. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  108. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  109. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  110. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  111. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  112. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  113. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  114. package/src/modules/workflow/git/git.service.ts +115 -7
  115. package/src/modules/workflow/workflow.routes.ts +104 -4
  116. package/src/modules/workflow/workflow.service.ts +245 -55
  117. package/src/modules/workspace/workspace.routes.ts +8 -4
  118. package/src/modules/workspace/workspace.service.ts +21 -3
  119. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  120. package/src/shared/token-crypto.ts +28 -10
@@ -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
- * Routes for the remote MCP server + the connection-key management endpoints.
33
- *
34
- * Layout:
35
- * POST /mcp — client→server MCP messages (initialize + tool calls)
36
- * GET /mcp — server→client SSE channel (session-bound)
37
- * DELETE /mcp — terminate a session
38
- *
39
- * GET /mcp/external-api-keys — list this user's connection keys
40
- * POST /mcp/external-api-keys — mint a new key (returns plaintext ONCE)
41
- * DELETE /mcp/external-api-keys/:id — revoke
42
- *
43
- * POST /mcp/local-token — exchange an MCP OAuth access token
44
- * for a loopback internal token
45
- *
46
- * The `/mcp` endpoints accept either a connection key or a JWT (see
47
- * McpAuthMiddleware). The `/mcp/external-api-keys/*` endpoints accept only the JWT —
48
- * minting/revoking via a connection key would let a leaked key roll itself
49
- * over and stay alive forever. `/mcp/local-token` accepts ONLY an MCP OAuth
50
- * access token — every other credential already opens the surface it bridges to.
51
- */
52
- export function createMcpRoutes(
53
- mcpService: McpService,
54
- externalApiKeyService: IExternalApiKeyService,
55
- mcpAuthMiddleware: RequestHandler,
56
- jwtAuthMiddleware: RequestHandler,
57
- llmUsageService: ILlmUsageMeter,
58
- internalTokens: InternalTokenService,
59
- oauthProvider: BevelOAuthProvider,
60
- // RFC 9728 pointer carried on this router's own 401 challenges (the
61
- // local-token exchange), same value McpAuthMiddleware advertises.
62
- resourceMetadataUrl: string,
63
- ): express.Router {
64
- const router = express.Router();
65
-
66
- // Per-process map of live MCP sessions. In-memory mirror of McpSessionStore
67
- // — McpSessionStore holds the *domain* state (userId, threadId, idle TTL),
68
- // this map holds the *transport* state (the SDK objects we need to route
69
- // a subsequent HTTP request to the correct session). Both are cleared on
70
- // restart; clients re-initialize.
71
- const active = new Map<string, ActiveSession>();
72
-
73
- // When McpSessionStore drops a session on its own (idle-TTL sweep or
74
- // size-cap eviction), the matching transport pair would otherwise leak
75
- // here — `active.has(id)` would still return true and route requests to a
76
- // McpServer whose backing domain state is gone. Subscribe to the store and
77
- // tear down the transport so the resources free immediately and the next
78
- // request with that id correctly 404s.
79
- mcpService.onSessionEvicted((sessionId) => {
80
- const entry = active.get(sessionId);
81
- if (!entry) return;
82
- active.delete(sessionId);
83
- // close() is async; fire-and-forget so a slow socket teardown can't
84
- // block the store's eviction loop. transport.onclose still fires but
85
- // its `active.delete` is now a no-op.
86
- void entry.transport.close().catch((err) => {
87
- console.warn('[mcp] transport close on eviction failed:', err);
88
- });
89
- });
90
-
91
- // ── MCP transport ──────────────────────────────────────────────────────
92
-
93
- router.post('/mcp', mcpAuthMiddleware, async (req, res) => {
94
- try {
95
- const sessionIdHeader = req.headers['mcp-session-id'] as string | undefined;
96
- const body = req.body;
97
-
98
- // Three valid request shapes on POST /mcp:
99
- // 1. No session id + body is initialize → spin up a new transport.
100
- // 2. Session id + matching active session → forward.
101
- // 3. Anything else → 400 / 404.
102
- if (sessionIdHeader && active.has(sessionIdHeader)) {
103
- const session = active.get(sessionIdHeader)!;
104
- // Defense in depth: the auth middleware bound a userId for this
105
- // request; refuse if it doesn't match the session's owner. Stops
106
- // a leaked session id from being used cross-user even if the
107
- // attacker has their own valid connection key.
108
- if (session.userId !== req.userId) {
109
- res.status(403).json({ error: 'Session does not belong to this user' });
110
- return;
111
- }
112
- await session.transport.handleRequest(req, res, body);
113
- return;
114
- }
115
-
116
- if (!sessionIdHeader && isInitializeRequest(body)) {
117
- // The proxy authenticates its loopback calls with the SAME bearer the
118
- // client used here, so it acts on the request exactly as the caller
119
- // would. Captured at initialize and seeded into the session's UtcpClient.
120
- const { transport, server } = await mcpService.createSession(
121
- req.userId!,
122
- // Connection-key id (set by mcpAuthMiddleware for `bevel_…` bearers;
123
- // undefined for browser JWT) — kept for audit/diagnostics.
124
- req.externalApiKeyId ?? null,
125
- extractBearer(req),
126
- (sessionId) => {
127
- active.set(sessionId, { transport, server, userId: req.userId! });
128
- },
129
- );
130
- // Closing the transport (DELETE /mcp, or client disconnect during
131
- // close) should drop both the active map and the McpSessionStore
132
- // entry. McpService wired onsessionclosed → sessionStore.delete; we
133
- // mirror it here.
134
- transport.onclose = () => {
135
- if (transport.sessionId) active.delete(transport.sessionId);
136
- };
137
- await server.connect(transport);
138
- await transport.handleRequest(req, res, body);
139
- return;
140
- }
141
-
142
- res.status(400).json({
143
- error:
144
- 'Bad request: missing session id, or session id does not match any active session.',
145
- });
146
- } catch (err) {
147
- const msg = err instanceof Error ? err.message : 'Unknown error';
148
- console.error('[mcp] POST /mcp failed:', msg);
149
- if (!res.headersSent) {
150
- res.status(500).json({ error: msg });
151
- } else {
152
- res.end();
153
- }
154
- }
155
- });
156
-
157
- const sessionRequest: RequestHandler = async (req, res) => {
158
- const sessionIdHeader = req.headers['mcp-session-id'] as string | undefined;
159
- if (!sessionIdHeader || !active.has(sessionIdHeader)) {
160
- res.status(404).json({ error: 'Unknown MCP session' });
161
- return;
162
- }
163
- const session = active.get(sessionIdHeader)!;
164
- if (session.userId !== req.userId) {
165
- res.status(403).json({ error: 'Session does not belong to this user' });
166
- return;
167
- }
168
- try {
169
- await session.transport.handleRequest(req, res);
170
- } catch (err) {
171
- const msg = err instanceof Error ? err.message : 'Unknown error';
172
- console.error('[mcp] session request failed:', msg);
173
- if (!res.headersSent) res.status(500).json({ error: msg });
174
- else res.end();
175
- }
176
- };
177
-
178
- // GET is the server→client SSE channel for session-scoped notifications.
179
- router.get('/mcp', mcpAuthMiddleware, sessionRequest);
180
-
181
- // DELETE terminates the session — SDK closes the transport, our
182
- // onclose handler cleans up the active map, and McpService's
183
- // onsessionclosed clears the McpSessionStore entry.
184
- router.delete('/mcp', mcpAuthMiddleware, sessionRequest);
185
-
186
- // ── Connection-key management (JWT-only) ───────────────────────────────
187
-
188
- router.get('/mcp/external-api-keys', jwtAuthMiddleware, async (req, res) => {
189
- try {
190
- const tokens = await externalApiKeyService.listForUser(req.userId!);
191
- // Enrich each key with its LLM-proxy usage today + the daily cap, so the
192
- // settings UI can show how much of the model budget the key has spent.
193
- const usage = await llmUsageService.usageForTokens(tokens.map((t) => t.id));
194
- res.json(
195
- tokens.map((t) => ({
196
- ...t,
197
- llmUsage: {
198
- usedTodayTokens: usage[t.id] ?? 0,
199
- dailyTokenCap: llmUsageService.dailyCap,
200
- },
201
- })),
202
- );
203
- } catch (err) {
204
- const msg = err instanceof Error ? err.message : 'Unknown error';
205
- res.status(500).json({ error: msg });
206
- }
207
- });
208
-
209
- router.post('/mcp/external-api-keys', jwtAuthMiddleware, async (req, res) => {
210
- try {
211
- // `express.json()` leaves req.body undefined when the client posts
212
- // without `Content-Type: application/json` (or with another type
213
- // entirely). Destructuring undefined throws a TypeError that the outer
214
- // catch would 500 — guard with a clean 400 instead.
215
- if (!req.body || typeof req.body !== 'object') {
216
- res.status(400).json({ error: 'JSON body required' });
217
- return;
218
- }
219
- const { label } = req.body as { label?: string };
220
- if (!label) {
221
- res.status(400).json({ error: 'label is required' });
222
- return;
223
- }
224
- const minted = await externalApiKeyService.mint(req.userId!, label);
225
- // The plaintext field is the *only* read path for the raw key. The
226
- // frontend must store it nowhere — it shows the dialog once and then
227
- // discards. Subsequent fetches return only `summary`-shaped rows.
228
- res.json(minted);
229
- } catch (err) {
230
- if (err instanceof InvalidTokenLabelError) {
231
- res.status(400).json({ error: err.message });
232
- return;
233
- }
234
- const msg = err instanceof Error ? err.message : 'Unknown error';
235
- res.status(500).json({ error: msg });
236
- }
237
- });
238
-
239
- router.delete('/mcp/external-api-keys/:id', jwtAuthMiddleware, async (req, res) => {
240
- try {
241
- await externalApiKeyService.revoke(String(req.params.id), req.userId!);
242
- res.json({ status: 'revoked' });
243
- } catch (err) {
244
- if (err instanceof TokenNotFoundError) {
245
- res.status(404).json({ error: err.message });
246
- return;
247
- }
248
- const msg = err instanceof Error ? err.message : 'Unknown error';
249
- res.status(500).json({ error: msg });
250
- }
251
- });
252
-
253
- // Hard-delete: permanently remove a *disconnected* key and its audit row.
254
- // Separate path from revoke so the two lifecycle steps can't be conflated;
255
- // the service refuses to delete a still-active key (409).
256
- router.delete('/mcp/external-api-keys/:id/permanent', jwtAuthMiddleware, async (req, res) => {
257
- try {
258
- await externalApiKeyService.remove(String(req.params.id), req.userId!);
259
- res.json({ status: 'deleted' });
260
- } catch (err) {
261
- if (err instanceof TokenNotFoundError) {
262
- res.status(404).json({ error: err.message });
263
- return;
264
- }
265
- if (err instanceof TokenStillActiveError) {
266
- res.status(409).json({ error: err.message });
267
- return;
268
- }
269
- const msg = err instanceof Error ? err.message : 'Unknown error';
270
- res.status(500).json({ error: msg });
271
- }
272
- });
273
-
274
- // ── Local-server token exchange (OAuth-access-token-only) ──────────────
275
-
276
- /**
277
- * Exchange an MCP OAuth access token for a short-lived internal token.
278
- *
279
- * Why it exists: the LOCAL MCP server's REST reads — the all-tools manual,
280
- * `list_local_tools`, the plugin archive — live on `/api/agent/*`, which
281
- * accepts connection keys and internal tokens ONLY; an MCP OAuth access
282
- * token deliberately 401s there. Hosted OAuth sessions cross that gap
283
- * inside `McpService.createSession`, which mints a loopback internal token
284
- * for the resolved user. This endpoint is the same exchange for an external
285
- * caller: the one bridge that lets a local server configured via the
286
- * deployment's MCP OAuth (instead of a connection key) reach those reads.
287
- *
288
- * Why it is NOT a widening of the trust boundary: the caller must present a
289
- * VERIFIED OAuth grant for this exact user — the same credential that
290
- * already drives full tool execution through the hosted `/mcp` endpoint.
291
- * The minted token is identical in shape to createSession's loopback bearer
292
- * (`{ userId, externalProxy: true }` → resolved as `source: 'external'` by
293
- * the tool-auth verifier, admitted to the external surface, refused from
294
- * internal-only tools) and carries the same TTL — CAPPED to the presented
295
- * access token's remaining lifetime, so the exchange can never mint a
296
- * credential that outlives its grant. Nothing becomes reachable that the
297
- * grant did not already reach — only the credential's spelling changes.
298
- *
299
- * Auth semantics mirror McpAuthMiddleware's OAuth branch: an
300
- * invalid/expired/revoked token is a 401 re-challenging with
301
- * `resource_metadata` (RFC 9728) so the client can re-authorize; a backend
302
- * failure during verification is a 500. A connection key, internal token,
303
- * or JWT is a 403 — those credentials need no exchange, so accepting them
304
- * here would only manufacture a second credential from a first.
305
- *
306
- * Response: `{ token, expiresInMs }`.
307
- */
308
- router.post('/mcp/local-token', async (req, res) => {
309
- const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
310
- const unauthorized = (error: string) => {
311
- res.setHeader('WWW-Authenticate', wwwAuthenticate);
312
- res.status(401).json({ error });
313
- };
314
-
315
- const header = req.headers.authorization;
316
- if (!header || !header.toLowerCase().startsWith('bearer ')) {
317
- unauthorized('Missing or invalid Authorization header');
318
- return;
319
- }
320
- const token = extractBearer(req);
321
-
322
- if (!oauthProvider.looksLikeAccessToken(token)) {
323
- // A recognizable non-OAuth credential gets an explicit 403: a
324
- // connection key or internal token already opens `/api/agent/*`
325
- // directly, and a JWT holder mints a connection key from the settings
326
- // UI — none of them has anything to exchange.
327
- if (
328
- externalApiKeyService.looksLikeExternalApiKey(token) ||
329
- internalTokens.looksLikeInternalToken(token) ||
330
- token.startsWith('eyJ')
331
- ) {
332
- res.status(403).json({
333
- error:
334
- 'This endpoint exchanges MCP OAuth access tokens only. Connection keys, ' +
335
- 'internal tokens, and JWTs need no exchange — use them directly.',
336
- });
337
- return;
338
- }
339
- // Unrecognizable bearer — re-challenge so an OAuth-capable client can
340
- // discover the authorization server and obtain a real access token.
341
- unauthorized('Invalid access token');
342
- return;
343
- }
344
-
345
- try {
346
- const info = await oauthProvider.verifyAccessToken(token);
347
- const userId = String(info.extra?.userId ?? '');
348
- if (!userId) {
349
- unauthorized('Invalid access token');
350
- return;
351
- }
352
- // The minted token must never OUTLIVE the grant that authorized it: an
353
- // OAuth access token revoked-by-expiry would otherwise leave a live
354
- // internal token behind for the rest of the loopback TTL. Bind the TTL
355
- // to whichever ends first — the constant, or the access token's own
356
- // remaining lifetime (AuthInfo.expiresAt is epoch SECONDS, optional; a
357
- // provider that reports none falls back to the constant alone).
358
- const grantRemainingMs =
359
- typeof info.expiresAt === 'number' ? info.expiresAt * 1000 - Date.now() : undefined;
360
- // A grant with no life left mints NOTHING: a 200 carrying an
361
- // already-dead token would read as success to the caller, whose first
362
- // real request then fails somewhere far from the cause. It is the same
363
- // 401 an expired token gets from the verifier, challenge and all — and
364
- // a non-finite expiresAt (a provider handing back garbage) is refused
365
- // the same way rather than turned into a TTL. Deliberately STRICTER
366
- // than the verifier at the boundary: AuthInfo floors the expiry to
367
- // whole seconds, so a grant inside its final partial second computes
368
- // as spent here even though the verifier (which compares the stored
369
- // millisecond timestamp) just accepted it — but that sub-second
370
- // remainder could only mint a token that is dead before its first use,
371
- // and refusing it is exactly this guard's job.
372
- if (grantRemainingMs !== undefined && !(Number.isFinite(grantRemainingMs) && grantRemainingMs > 0)) {
373
- unauthorized('Invalid, expired, or revoked access token');
374
- return;
375
- }
376
- const ttlMs =
377
- grantRemainingMs === undefined
378
- ? MCP_LOOPBACK_TOKEN_TTL_MS
379
- : Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
380
- const minted = internalTokens.mint({ userId, externalProxy: true }, ttlMs);
381
- // The ACTUAL lifetime, not the constant — the caller schedules its
382
- // proactive renewal off this number.
383
- res.json({ token: minted, expiresInMs: ttlMs });
384
- } catch (err) {
385
- // Same split as McpAuthMiddleware: a bad token is a clean 401 with the
386
- // discovery challenge; a backend failure is a 500 — the credential may
387
- // be fine, we just can't check it right now.
388
- if (err instanceof InvalidTokenError) {
389
- unauthorized('Invalid, expired, or revoked access token');
390
- } else {
391
- console.error('[mcp] local-token exchange failed:', err);
392
- res.status(500).json({ error: 'Authentication backend unavailable' });
393
- }
394
- }
395
- });
396
-
397
- return router;
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
+ }