@desplega.ai/agent-swarm 1.68.0 → 1.69.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.
@@ -0,0 +1,456 @@
1
+ import { decryptSecret, encryptSecret, getEncryptionKey } from "../crypto";
2
+ import { normalizeDateRequired } from "../date-utils";
3
+ import { getDb } from "../db";
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────────────
6
+
7
+ export type McpOAuthStatus = "connected" | "expired" | "error" | "revoked";
8
+ export type McpOAuthClientSource = "dcr" | "manual" | "preregistered";
9
+
10
+ /**
11
+ * Token row as stored in SQLite. `accessToken`, `refreshToken`, and
12
+ * `dcrClientSecret` are encrypted at rest — always go through the exported
13
+ * getters/inserts, never query the table directly.
14
+ */
15
+ interface McpOAuthTokenRow {
16
+ id: string;
17
+ mcpServerId: string;
18
+ userId: string | null;
19
+ accessToken: string;
20
+ refreshToken: string | null;
21
+ tokenType: string;
22
+ expiresAt: string | null;
23
+ scope: string | null;
24
+ resourceUrl: string;
25
+ authorizationServerIssuer: string;
26
+ authorizeUrl: string;
27
+ tokenUrl: string;
28
+ revocationUrl: string | null;
29
+ dcrClientId: string | null;
30
+ dcrClientSecret: string | null;
31
+ clientSource: McpOAuthClientSource;
32
+ status: McpOAuthStatus;
33
+ lastErrorMessage: string | null;
34
+ lastRefreshedAt: string | null;
35
+ connectedByUserId: string | null;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ }
39
+
40
+ export interface McpOAuthToken {
41
+ id: string;
42
+ mcpServerId: string;
43
+ userId: string | null;
44
+ /** Decrypted access token (plaintext). */
45
+ accessToken: string;
46
+ /** Decrypted refresh token (plaintext), or null. */
47
+ refreshToken: string | null;
48
+ tokenType: string;
49
+ expiresAt: string | null;
50
+ scope: string | null;
51
+ resourceUrl: string;
52
+ authorizationServerIssuer: string;
53
+ authorizeUrl: string;
54
+ tokenUrl: string;
55
+ revocationUrl: string | null;
56
+ dcrClientId: string | null;
57
+ /** Decrypted DCR client secret (plaintext), or null. */
58
+ dcrClientSecret: string | null;
59
+ clientSource: McpOAuthClientSource;
60
+ status: McpOAuthStatus;
61
+ lastErrorMessage: string | null;
62
+ lastRefreshedAt: string | null;
63
+ connectedByUserId: string | null;
64
+ createdAt: string;
65
+ updatedAt: string;
66
+ }
67
+
68
+ export interface McpOAuthPendingRow {
69
+ state: string;
70
+ mcpServerId: string;
71
+ userId: string | null;
72
+ /** Decrypted PKCE code verifier (plaintext). */
73
+ codeVerifier: string;
74
+ nonce: string | null;
75
+ resourceUrl: string;
76
+ authorizationServerIssuer: string;
77
+ authorizeUrl: string;
78
+ tokenUrl: string;
79
+ revocationUrl: string | null;
80
+ scopes: string | null;
81
+ dcrClientId: string | null;
82
+ /** Decrypted DCR client secret (plaintext), or null. */
83
+ dcrClientSecret: string | null;
84
+ redirectUri: string;
85
+ finalRedirect: string | null;
86
+ createdAt: string;
87
+ }
88
+
89
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
90
+
91
+ function encryptOrNull(plaintext: string | null | undefined): string | null {
92
+ if (plaintext == null || plaintext === "") return null;
93
+ return encryptSecret(plaintext, getEncryptionKey());
94
+ }
95
+
96
+ function decryptOrNull(ciphertext: string | null | undefined): string | null {
97
+ if (ciphertext == null || ciphertext === "") return null;
98
+ return decryptSecret(ciphertext, getEncryptionKey());
99
+ }
100
+
101
+ function decryptTokenRow(row: McpOAuthTokenRow): McpOAuthToken {
102
+ const key = getEncryptionKey();
103
+ return {
104
+ ...row,
105
+ accessToken: decryptSecret(row.accessToken, key),
106
+ refreshToken: decryptOrNull(row.refreshToken),
107
+ dcrClientSecret: decryptOrNull(row.dcrClientSecret),
108
+ createdAt: normalizeDateRequired(row.createdAt),
109
+ updatedAt: normalizeDateRequired(row.updatedAt),
110
+ };
111
+ }
112
+
113
+ // ─── mcp_oauth_tokens ────────────────────────────────────────────────────────
114
+
115
+ export function getMcpOAuthToken(
116
+ mcpServerId: string,
117
+ userId: string | null = null,
118
+ ): McpOAuthToken | null {
119
+ const row = getDb()
120
+ .query(
121
+ userId == null
122
+ ? "SELECT * FROM mcp_oauth_tokens WHERE mcpServerId = ? AND userId IS NULL"
123
+ : "SELECT * FROM mcp_oauth_tokens WHERE mcpServerId = ? AND userId = ?",
124
+ )
125
+ .get(...(userId == null ? [mcpServerId] : [mcpServerId, userId])) as McpOAuthTokenRow | null;
126
+ return row ? decryptTokenRow(row) : null;
127
+ }
128
+
129
+ export function getMcpOAuthTokenById(id: string): McpOAuthToken | null {
130
+ const row = getDb()
131
+ .query("SELECT * FROM mcp_oauth_tokens WHERE id = ?")
132
+ .get(id) as McpOAuthTokenRow | null;
133
+ return row ? decryptTokenRow(row) : null;
134
+ }
135
+
136
+ export function listMcpOAuthTokensForMcp(mcpServerId: string): McpOAuthToken[] {
137
+ const rows = getDb()
138
+ .query("SELECT * FROM mcp_oauth_tokens WHERE mcpServerId = ?")
139
+ .all(mcpServerId) as McpOAuthTokenRow[];
140
+ return rows.map(decryptTokenRow);
141
+ }
142
+
143
+ export interface UpsertMcpOAuthTokenInput {
144
+ mcpServerId: string;
145
+ userId?: string | null;
146
+ accessToken: string;
147
+ refreshToken?: string | null;
148
+ tokenType?: string;
149
+ expiresAt?: string | null;
150
+ scope?: string | null;
151
+ resourceUrl: string;
152
+ authorizationServerIssuer: string;
153
+ authorizeUrl: string;
154
+ tokenUrl: string;
155
+ revocationUrl?: string | null;
156
+ dcrClientId?: string | null;
157
+ dcrClientSecret?: string | null;
158
+ clientSource: McpOAuthClientSource;
159
+ status?: McpOAuthStatus;
160
+ lastErrorMessage?: string | null;
161
+ lastRefreshedAt?: string | null;
162
+ connectedByUserId?: string | null;
163
+ }
164
+
165
+ /**
166
+ * Insert or update the token for a given (mcpServerId, userId) pair.
167
+ * Encrypts accessToken/refreshToken/dcrClientSecret at write time.
168
+ *
169
+ * Uses explicit select-then-insert-or-update rather than ON CONFLICT because
170
+ * SQLite treats NULL values in UNIQUE(mcpServerId, userId) as distinct, so the
171
+ * v1 per-swarm case (userId IS NULL) would never trigger the conflict path.
172
+ */
173
+ export function upsertMcpOAuthToken(input: UpsertMcpOAuthTokenInput): void {
174
+ const userId = input.userId ?? null;
175
+ const encryptedAccess = encryptSecret(input.accessToken, getEncryptionKey());
176
+ const encryptedRefresh = encryptOrNull(input.refreshToken);
177
+ const encryptedClientSecret = encryptOrNull(input.dcrClientSecret);
178
+
179
+ const existing = getMcpOAuthToken(input.mcpServerId, userId);
180
+ if (!existing) {
181
+ getDb()
182
+ .query(
183
+ `INSERT INTO mcp_oauth_tokens (
184
+ mcpServerId, userId,
185
+ accessToken, refreshToken, tokenType, expiresAt, scope,
186
+ resourceUrl, authorizationServerIssuer, authorizeUrl, tokenUrl, revocationUrl,
187
+ dcrClientId, dcrClientSecret, clientSource,
188
+ status, lastErrorMessage, lastRefreshedAt, connectedByUserId
189
+ )
190
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
191
+ )
192
+ .run(
193
+ input.mcpServerId,
194
+ userId,
195
+ encryptedAccess,
196
+ encryptedRefresh,
197
+ input.tokenType ?? "Bearer",
198
+ input.expiresAt ?? null,
199
+ input.scope ?? null,
200
+ input.resourceUrl,
201
+ input.authorizationServerIssuer,
202
+ input.authorizeUrl,
203
+ input.tokenUrl,
204
+ input.revocationUrl ?? null,
205
+ input.dcrClientId ?? null,
206
+ encryptedClientSecret,
207
+ input.clientSource,
208
+ input.status ?? "connected",
209
+ input.lastErrorMessage ?? null,
210
+ input.lastRefreshedAt ?? null,
211
+ input.connectedByUserId ?? null,
212
+ );
213
+ return;
214
+ }
215
+
216
+ getDb()
217
+ .query(
218
+ `UPDATE mcp_oauth_tokens SET
219
+ accessToken = ?,
220
+ refreshToken = COALESCE(?, refreshToken),
221
+ tokenType = ?,
222
+ expiresAt = ?,
223
+ scope = COALESCE(?, scope),
224
+ resourceUrl = ?,
225
+ authorizationServerIssuer = ?,
226
+ authorizeUrl = ?,
227
+ tokenUrl = ?,
228
+ revocationUrl = ?,
229
+ dcrClientId = COALESCE(?, dcrClientId),
230
+ dcrClientSecret = COALESCE(?, dcrClientSecret),
231
+ clientSource = ?,
232
+ status = ?,
233
+ lastErrorMessage = ?,
234
+ lastRefreshedAt = ?,
235
+ connectedByUserId = COALESCE(?, connectedByUserId),
236
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
237
+ WHERE id = ?`,
238
+ )
239
+ .run(
240
+ encryptedAccess,
241
+ encryptedRefresh,
242
+ input.tokenType ?? "Bearer",
243
+ input.expiresAt ?? null,
244
+ input.scope ?? null,
245
+ input.resourceUrl,
246
+ input.authorizationServerIssuer,
247
+ input.authorizeUrl,
248
+ input.tokenUrl,
249
+ input.revocationUrl ?? null,
250
+ input.dcrClientId ?? null,
251
+ encryptedClientSecret,
252
+ input.clientSource,
253
+ input.status ?? "connected",
254
+ input.lastErrorMessage ?? null,
255
+ input.lastRefreshedAt ?? null,
256
+ input.connectedByUserId ?? null,
257
+ existing.id,
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Apply a refresh result: rewrite access token, optionally refresh token, and
263
+ * bump expiresAt + status. Does not touch AS metadata.
264
+ */
265
+ export function applyMcpOAuthRefresh(
266
+ id: string,
267
+ data: {
268
+ accessToken: string;
269
+ refreshToken?: string | null;
270
+ expiresAt?: string | null;
271
+ scope?: string | null;
272
+ },
273
+ ): void {
274
+ const key = getEncryptionKey();
275
+ const encryptedAccess = encryptSecret(data.accessToken, key);
276
+ const encryptedRefresh =
277
+ data.refreshToken === undefined
278
+ ? undefined
279
+ : data.refreshToken == null
280
+ ? null
281
+ : encryptSecret(data.refreshToken, key);
282
+
283
+ if (encryptedRefresh === undefined) {
284
+ getDb()
285
+ .query(
286
+ `UPDATE mcp_oauth_tokens
287
+ SET accessToken = ?,
288
+ expiresAt = COALESCE(?, expiresAt),
289
+ scope = COALESCE(?, scope),
290
+ status = 'connected',
291
+ lastErrorMessage = NULL,
292
+ lastRefreshedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
293
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
294
+ WHERE id = ?`,
295
+ )
296
+ .run(encryptedAccess, data.expiresAt ?? null, data.scope ?? null, id);
297
+ } else {
298
+ getDb()
299
+ .query(
300
+ `UPDATE mcp_oauth_tokens
301
+ SET accessToken = ?,
302
+ refreshToken = ?,
303
+ expiresAt = COALESCE(?, expiresAt),
304
+ scope = COALESCE(?, scope),
305
+ status = 'connected',
306
+ lastErrorMessage = NULL,
307
+ lastRefreshedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
308
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
309
+ WHERE id = ?`,
310
+ )
311
+ .run(encryptedAccess, encryptedRefresh, data.expiresAt ?? null, data.scope ?? null, id);
312
+ }
313
+ }
314
+
315
+ export function markMcpOAuthTokenStatus(
316
+ id: string,
317
+ status: McpOAuthStatus,
318
+ errorMessage?: string | null,
319
+ ): void {
320
+ getDb()
321
+ .query(
322
+ `UPDATE mcp_oauth_tokens
323
+ SET status = ?,
324
+ lastErrorMessage = ?,
325
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
326
+ WHERE id = ?`,
327
+ )
328
+ .run(status, errorMessage ?? null, id);
329
+ }
330
+
331
+ export function deleteMcpOAuthToken(mcpServerId: string, userId: string | null = null): boolean {
332
+ const result = getDb()
333
+ .query(
334
+ userId == null
335
+ ? "DELETE FROM mcp_oauth_tokens WHERE mcpServerId = ? AND userId IS NULL"
336
+ : "DELETE FROM mcp_oauth_tokens WHERE mcpServerId = ? AND userId = ?",
337
+ )
338
+ .run(...(userId == null ? [mcpServerId] : [mcpServerId, userId]));
339
+ return result.changes > 0;
340
+ }
341
+
342
+ export function isMcpTokenExpiringSoon(token: McpOAuthToken, bufferMs = 5 * 60 * 1000): boolean {
343
+ if (!token.expiresAt) return false;
344
+ const expiresAt = new Date(token.expiresAt).getTime();
345
+ if (Number.isNaN(expiresAt)) return true;
346
+ return expiresAt - Date.now() < bufferMs;
347
+ }
348
+
349
+ // ─── mcp_oauth_pending ───────────────────────────────────────────────────────
350
+
351
+ export interface InsertMcpOAuthPendingInput {
352
+ state: string;
353
+ mcpServerId: string;
354
+ userId?: string | null;
355
+ codeVerifier: string;
356
+ nonce?: string | null;
357
+ resourceUrl: string;
358
+ authorizationServerIssuer: string;
359
+ authorizeUrl: string;
360
+ tokenUrl: string;
361
+ revocationUrl?: string | null;
362
+ scopes?: string | null;
363
+ dcrClientId?: string | null;
364
+ dcrClientSecret?: string | null;
365
+ redirectUri: string;
366
+ finalRedirect?: string | null;
367
+ }
368
+
369
+ export function insertMcpOAuthPending(input: InsertMcpOAuthPendingInput): void {
370
+ const key = getEncryptionKey();
371
+ getDb()
372
+ .query(
373
+ `INSERT INTO mcp_oauth_pending (
374
+ state, mcpServerId, userId,
375
+ codeVerifier, nonce,
376
+ resourceUrl, authorizationServerIssuer, authorizeUrl, tokenUrl, revocationUrl,
377
+ scopes, dcrClientId, dcrClientSecret, redirectUri, finalRedirect
378
+ )
379
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
380
+ )
381
+ .run(
382
+ input.state,
383
+ input.mcpServerId,
384
+ input.userId ?? null,
385
+ encryptSecret(input.codeVerifier, key),
386
+ input.nonce ?? null,
387
+ input.resourceUrl,
388
+ input.authorizationServerIssuer,
389
+ input.authorizeUrl,
390
+ input.tokenUrl,
391
+ input.revocationUrl ?? null,
392
+ input.scopes ?? null,
393
+ input.dcrClientId ?? null,
394
+ encryptOrNull(input.dcrClientSecret),
395
+ input.redirectUri,
396
+ input.finalRedirect ?? null,
397
+ );
398
+ }
399
+
400
+ interface McpOAuthPendingRawRow {
401
+ state: string;
402
+ mcpServerId: string;
403
+ userId: string | null;
404
+ codeVerifier: string;
405
+ nonce: string | null;
406
+ resourceUrl: string;
407
+ authorizationServerIssuer: string;
408
+ authorizeUrl: string;
409
+ tokenUrl: string;
410
+ revocationUrl: string | null;
411
+ scopes: string | null;
412
+ dcrClientId: string | null;
413
+ dcrClientSecret: string | null;
414
+ redirectUri: string;
415
+ finalRedirect: string | null;
416
+ createdAt: string;
417
+ }
418
+
419
+ export function consumeMcpOAuthPending(state: string): McpOAuthPendingRow | null {
420
+ const row = getDb()
421
+ .query("SELECT * FROM mcp_oauth_pending WHERE state = ?")
422
+ .get(state) as McpOAuthPendingRawRow | null;
423
+ if (!row) return null;
424
+ getDb().query("DELETE FROM mcp_oauth_pending WHERE state = ?").run(state);
425
+ const key = getEncryptionKey();
426
+ return {
427
+ ...row,
428
+ codeVerifier: decryptSecret(row.codeVerifier, key),
429
+ dcrClientSecret: decryptOrNull(row.dcrClientSecret),
430
+ };
431
+ }
432
+
433
+ export function gcMcpOAuthPending(olderThanMs = 10 * 60 * 1000): number {
434
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
435
+ const result = getDb().query("DELETE FROM mcp_oauth_pending WHERE createdAt < ?").run(cutoff);
436
+ return result.changes;
437
+ }
438
+
439
+ // ─── mcp_servers.authMethod ──────────────────────────────────────────────────
440
+
441
+ export type McpAuthMethod = "static" | "oauth" | "auto";
442
+
443
+ export function getMcpServerAuthMethod(mcpServerId: string): McpAuthMethod | null {
444
+ const row = getDb().query("SELECT authMethod FROM mcp_servers WHERE id = ?").get(mcpServerId) as {
445
+ authMethod: McpAuthMethod;
446
+ } | null;
447
+ return row?.authMethod ?? null;
448
+ }
449
+
450
+ export function setMcpServerAuthMethod(mcpServerId: string, authMethod: McpAuthMethod): void {
451
+ getDb()
452
+ .query(
453
+ "UPDATE mcp_servers SET authMethod = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
454
+ )
455
+ .run(authMethod, mcpServerId);
456
+ }
package/src/be/db.ts CHANGED
@@ -7067,6 +7067,7 @@ type McpServerRow = {
7067
7067
  headers: string | null;
7068
7068
  envConfigKeys: string | null;
7069
7069
  headerConfigKeys: string | null;
7070
+ authMethod: string | null;
7070
7071
  isEnabled: number;
7071
7072
  version: number;
7072
7073
  createdAt: string;
@@ -7097,6 +7098,7 @@ function rowToMcpServer(row: McpServerRow): McpServer {
7097
7098
  headers: row.headers,
7098
7099
  envConfigKeys: row.envConfigKeys,
7099
7100
  headerConfigKeys: row.headerConfigKeys,
7101
+ authMethod: (row.authMethod as McpServer["authMethod"]) ?? "static",
7100
7102
  isEnabled: row.isEnabled === 1,
7101
7103
  version: row.version,
7102
7104
  createdAt: row.createdAt,
@@ -7172,7 +7174,10 @@ export function createMcpServer(data: McpServerInsert): McpServer {
7172
7174
 
7173
7175
  export function updateMcpServer(
7174
7176
  id: string,
7175
- updates: Partial<McpServerInsert> & { isEnabled?: boolean },
7177
+ updates: Partial<McpServerInsert> & {
7178
+ isEnabled?: boolean;
7179
+ authMethod?: McpServer["authMethod"];
7180
+ },
7176
7181
  ): McpServer | null {
7177
7182
  const existing = getMcpServerById(id);
7178
7183
  if (!existing) return null;
@@ -7229,6 +7234,10 @@ export function updateMcpServer(
7229
7234
  sets.push("ownerAgentId = ?");
7230
7235
  params.push(updates.ownerAgentId ?? null);
7231
7236
  }
7237
+ if (updates.authMethod !== undefined) {
7238
+ sets.push("authMethod = ?");
7239
+ params.push(updates.authMethod);
7240
+ }
7232
7241
 
7233
7242
  // Bump version on config changes
7234
7243
  const configFields = [
@@ -0,0 +1,84 @@
1
+ -- OAuth tokens scoped per MCP server installation.
2
+ --
3
+ -- Distinct from oauth_tokens (009_tracker_integration.sql) which is keyed by a
4
+ -- single global `provider` string (one Linear per swarm, etc.). MCP tokens need
5
+ -- one row per mcp_servers.id.
6
+ --
7
+ -- Encryption: accessToken, refreshToken, dcrClientSecret are stored as
8
+ -- base64(iv || ciphertext || authTag) AES-256-GCM using SECRETS_ENCRYPTION_KEY
9
+ -- (same helper as swarm_config, migration 038). Never written plaintext.
10
+ --
11
+ -- v1 is per-swarm (userId IS NULL). The UNIQUE(mcpServerId, userId) constraint
12
+ -- already admits a future per-user extension without a schema change.
13
+
14
+ CREATE TABLE IF NOT EXISTS mcp_oauth_tokens (
15
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
16
+ mcpServerId TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
17
+ userId TEXT REFERENCES users(id) ON DELETE CASCADE,
18
+
19
+ -- Token material (AES-256-GCM, base64-encoded; never plaintext)
20
+ accessToken TEXT NOT NULL,
21
+ refreshToken TEXT,
22
+ tokenType TEXT NOT NULL DEFAULT 'Bearer',
23
+ expiresAt TEXT,
24
+ scope TEXT,
25
+
26
+ -- Authorization server context (discovered via PRMD/AS metadata at connect time)
27
+ resourceUrl TEXT NOT NULL,
28
+ authorizationServerIssuer TEXT NOT NULL,
29
+ authorizeUrl TEXT NOT NULL,
30
+ tokenUrl TEXT NOT NULL,
31
+ revocationUrl TEXT,
32
+
33
+ -- Client credentials (either RFC 7591 DCR result or user-supplied)
34
+ dcrClientId TEXT,
35
+ dcrClientSecret TEXT,
36
+ clientSource TEXT NOT NULL CHECK(clientSource IN ('dcr','manual','preregistered')),
37
+
38
+ -- Connection state surfaced to the UI
39
+ status TEXT NOT NULL CHECK(status IN ('connected','expired','error','revoked')) DEFAULT 'connected',
40
+ lastErrorMessage TEXT,
41
+ lastRefreshedAt TEXT,
42
+
43
+ -- Audit
44
+ connectedByUserId TEXT REFERENCES users(id),
45
+ createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
46
+ updatedAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
+
48
+ UNIQUE(mcpServerId, userId)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_mcp ON mcp_oauth_tokens(mcpServerId);
52
+ CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_user ON mcp_oauth_tokens(userId);
53
+ CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_expires ON mcp_oauth_tokens(expiresAt);
54
+
55
+ -- Pending OAuth sessions (state -> PKCE verifier + mcpServerId) for the short
56
+ -- window between /authorize and /callback. Rows older than 10 minutes are GC'd
57
+ -- by a timer in the HTTP boot path.
58
+ CREATE TABLE IF NOT EXISTS mcp_oauth_pending (
59
+ state TEXT PRIMARY KEY,
60
+ mcpServerId TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
61
+ userId TEXT REFERENCES users(id),
62
+ codeVerifier TEXT NOT NULL,
63
+ nonce TEXT,
64
+ resourceUrl TEXT NOT NULL,
65
+ authorizationServerIssuer TEXT NOT NULL,
66
+ authorizeUrl TEXT NOT NULL,
67
+ tokenUrl TEXT NOT NULL,
68
+ revocationUrl TEXT,
69
+ scopes TEXT,
70
+ dcrClientId TEXT,
71
+ dcrClientSecret TEXT,
72
+ redirectUri TEXT NOT NULL,
73
+ finalRedirect TEXT,
74
+ createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_createdAt ON mcp_oauth_pending(createdAt);
78
+
79
+ -- Extend mcp_servers with an authMethod discriminator. Pre-existing rows default
80
+ -- to 'static' (today's behaviour). 'oauth' tells resolveSecrets to inject a
81
+ -- Bearer token from mcp_oauth_tokens instead of swarm_config headers. 'auto'
82
+ -- lets the API probe the MCP on save and flip to 'oauth' if required.
83
+ ALTER TABLE mcp_servers ADD COLUMN authMethod TEXT NOT NULL DEFAULT 'static'
84
+ CHECK(authMethod IN ('static','oauth','auto'));
package/src/http/index.ts CHANGED
@@ -28,6 +28,7 @@ import { handleEcosystem } from "./ecosystem";
28
28
  import { handleEvents } from "./events";
29
29
  import { handleHeartbeat } from "./heartbeat";
30
30
  import { handleMcp } from "./mcp";
31
+ import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
31
32
  import { handleMcpServers } from "./mcp-servers";
32
33
  import { handleMemory } from "./memory";
33
34
  import { handlePoll } from "./poll";
@@ -129,6 +130,7 @@ const httpServer = createHttpServer(async (req, res) => {
129
130
  () => handleRepos(req, res, pathSegments, queryParams),
130
131
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
131
132
  () => handleMcpServers(req, res, pathSegments, queryParams),
133
+ () => handleMcpOAuth(req, res, pathSegments, queryParams),
132
134
  () => handleMemory(req, res, pathSegments, myAgentId),
133
135
  () => handleApiKeys(req, res, pathSegments, queryParams),
134
136
  () => handleHeartbeat(req, res, pathSegments),
@@ -181,6 +183,9 @@ async function shutdown() {
181
183
  stopOAuthKeepalive();
182
184
  }
183
185
 
186
+ // Stop MCP OAuth pending-session garbage collector
187
+ stopMcpOAuthPendingGc();
188
+
184
189
  // Close all active transports (SSE connections, etc.)
185
190
  for (const [id, transport] of Object.entries(transports)) {
186
191
  console.log(`[HTTP] Closing transport ${id}`);
@@ -290,6 +295,9 @@ httpServer
290
295
  const { startOAuthKeepalive } = await import("../oauth/keepalive");
291
296
  startOAuthKeepalive();
292
297
  }
298
+
299
+ // Start MCP OAuth pending-session garbage collector (5-min tick)
300
+ startMcpOAuthPendingGc();
293
301
  })
294
302
  .on("error", (err) => {
295
303
  console.error("HTTP Server Error:", err);