burn-mcp-server 2.0.6 → 2.0.7
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/README.md +57 -2
- package/api/health.mjs +7 -0
- package/api/mcp.js +36075 -0
- package/dist/http.mjs +1131 -0
- package/dist/index.js +1021 -1009
- package/package.json +7 -3
- package/public/index.html +47 -0
- package/server.json +16 -2
- package/src/http-dev.ts +56 -0
- package/src/http.ts +62 -0
- package/src/index.ts +28 -1624
- package/src/lib/auth-stdio.ts +26 -0
- package/src/lib/auth.ts +71 -0
- package/src/setup.ts +1529 -0
- package/src/vercel-handler.ts +20 -0
- package/src/vercel-node-handler.ts +76 -0
- package/tsconfig.json +10 -6
- package/vercel.json +9 -0
package/src/index.ts
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Burn MCP — stdio entry (for Claude Desktop / Cursor / Windsurf local install)
|
|
3
|
+
// Usage: npx burn-mcp-server (BURN_MCP_TOKEN env required)
|
|
2
4
|
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
|
-
import { createClient
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// Config
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
6
|
+
import { createClient } from '@supabase/supabase-js'
|
|
7
|
+
import { exchangeToken, applySession } from './lib/auth.js'
|
|
8
|
+
import { loadCachedSession, saveCachedSession } from './lib/auth-stdio.js'
|
|
9
|
+
import { createBurnServer } from './setup.js'
|
|
11
10
|
|
|
12
11
|
const SUPABASE_URL = process.env.BURN_SUPABASE_URL || 'https://juqtxylquemiuvvmgbej.supabase.co'
|
|
13
12
|
const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO'
|
|
14
13
|
|
|
15
|
-
// Support both old JWT token (BURN_SUPABASE_TOKEN) and new long-lived MCP token (BURN_MCP_TOKEN)
|
|
16
14
|
const MCP_TOKEN = process.env.BURN_MCP_TOKEN
|
|
17
15
|
const LEGACY_JWT = process.env.BURN_SUPABASE_TOKEN
|
|
18
|
-
const EXCHANGE_URL = process.env.BURN_MCP_EXCHANGE_URL || 'https://api.burn451.cloud/api/mcp-exchange'
|
|
19
16
|
|
|
20
17
|
if (!MCP_TOKEN && !LEGACY_JWT) {
|
|
21
18
|
console.error('Error: BURN_MCP_TOKEN environment variable is required.')
|
|
@@ -23,1633 +20,40 @@ if (!MCP_TOKEN && !LEGACY_JWT) {
|
|
|
23
20
|
process.exit(1)
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
31
|
-
auth: {
|
|
32
|
-
persistSession: false,
|
|
33
|
-
autoRefreshToken: true,
|
|
34
|
-
},
|
|
35
|
-
...(LEGACY_JWT ? { global: { headers: { Authorization: `Bearer ${LEGACY_JWT}` } } } : {}),
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Auth: exchange MCP token for a real Supabase session (auto-refreshes)
|
|
40
|
-
// Caches session locally so exchange is only needed on first run or token expiry
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
import { homedir } from 'node:os'
|
|
44
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
45
|
-
import { join } from 'node:path'
|
|
46
|
-
|
|
47
|
-
const SESSION_CACHE_DIR = join(homedir(), '.burn')
|
|
48
|
-
const SESSION_CACHE_FILE = join(SESSION_CACHE_DIR, 'mcp-session.json')
|
|
49
|
-
|
|
50
|
-
function loadCachedSession(): { access_token: string; refresh_token: string } | null {
|
|
51
|
-
try {
|
|
52
|
-
const raw = readFileSync(SESSION_CACHE_FILE, 'utf-8')
|
|
53
|
-
const data = JSON.parse(raw)
|
|
54
|
-
if (data.access_token && data.refresh_token) return data
|
|
55
|
-
} catch { /* no cache or invalid */ }
|
|
56
|
-
return null
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function saveCachedSession(access_token: string, refresh_token: string): void {
|
|
60
|
-
try {
|
|
61
|
-
mkdirSync(SESSION_CACHE_DIR, { recursive: true })
|
|
62
|
-
writeFileSync(SESSION_CACHE_FILE, JSON.stringify({ access_token, refresh_token }), { mode: 0o600 })
|
|
63
|
-
} catch { /* non-fatal — next startup will re-exchange */ }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function initAuth(): Promise<void> {
|
|
67
|
-
if (LEGACY_JWT) return // legacy mode: JWT already set in headers above
|
|
68
|
-
|
|
69
|
-
// Step 1: Try cached session (avoids network call on every restart)
|
|
70
|
-
const cached = loadCachedSession()
|
|
71
|
-
if (cached) {
|
|
72
|
-
const { error } = await supabase.auth.setSession(cached)
|
|
73
|
-
if (!error) {
|
|
74
|
-
// Listen for token refresh so we keep the cache fresh
|
|
75
|
-
supabase.auth.onAuthStateChange((_event, session) => {
|
|
76
|
-
if (session?.access_token && session?.refresh_token) {
|
|
77
|
-
saveCachedSession(session.access_token, session.refresh_token)
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
console.error('Burn MCP: restored session from cache (no network needed)')
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
console.error('Burn MCP: cached session expired, re-exchanging...')
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Step 2: Exchange MCP token for a fresh Supabase session via Vercel API
|
|
87
|
-
try {
|
|
88
|
-
const resp = await fetch(EXCHANGE_URL, {
|
|
89
|
-
method: 'POST',
|
|
90
|
-
headers: { 'Content-Type': 'application/json' },
|
|
91
|
-
body: JSON.stringify({ token: MCP_TOKEN }),
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
if (!resp.ok) {
|
|
95
|
-
const body = await resp.json().catch(() => ({})) as any
|
|
96
|
-
console.error(`Error: Token exchange failed (${resp.status}): ${body.error || 'Unknown'}`)
|
|
97
|
-
console.error('Tokens expire after 30 days. Generate a new one in Burn App → Settings → MCP Server.')
|
|
98
|
-
process.exit(1)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const { access_token, refresh_token } = await resp.json() as any
|
|
102
|
-
|
|
103
|
-
const { error: sessionError } = await supabase.auth.setSession({
|
|
104
|
-
access_token,
|
|
105
|
-
refresh_token,
|
|
106
|
-
})
|
|
107
|
-
if (sessionError) {
|
|
108
|
-
console.error('Error: Failed to set session.', sessionError.message)
|
|
109
|
-
process.exit(1)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Cache for next startup + listen for refresh
|
|
113
|
-
saveCachedSession(access_token, refresh_token)
|
|
114
|
-
supabase.auth.onAuthStateChange((_event, session) => {
|
|
115
|
-
if (session?.access_token && session?.refresh_token) {
|
|
116
|
-
saveCachedSession(session.access_token, session.refresh_token)
|
|
117
|
-
}
|
|
118
|
-
})
|
|
119
|
-
console.error('Burn MCP: session exchanged and cached')
|
|
120
|
-
} catch (err: any) {
|
|
121
|
-
console.error('Error: Could not reach token exchange endpoint.', err.message)
|
|
122
|
-
console.error(`URL: ${EXCHANGE_URL}`)
|
|
123
|
-
process.exit(1)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
// Rate limiter — simple sliding window (per MCP session, in-memory)
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
|
|
132
|
-
const RATE_LIMIT_MAX_CALLS = 30 // max tool calls per window
|
|
133
|
-
|
|
134
|
-
const rateLimitLog: number[] = []
|
|
135
|
-
|
|
136
|
-
function checkRateLimit(): string | null {
|
|
137
|
-
const now = Date.now()
|
|
138
|
-
// Remove entries outside the window
|
|
139
|
-
while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
140
|
-
rateLimitLog.shift()
|
|
141
|
-
}
|
|
142
|
-
if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
|
|
143
|
-
const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1000)
|
|
144
|
-
return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`
|
|
145
|
-
}
|
|
146
|
-
rateLimitLog.push(now)
|
|
147
|
-
return null
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// MCP Server
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
const server = new McpServer({
|
|
155
|
-
name: 'burn-mcp-server',
|
|
156
|
-
version: '2.0.0',
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Helper: standard text result
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
function textResult(text: string) {
|
|
164
|
-
return { content: [{ type: 'text' as const, text }] }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
// Helper: verify bookmark exists and has expected status
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
async function verifyBookmark(
|
|
172
|
-
id: string,
|
|
173
|
-
expectedStatus?: string | string[]
|
|
174
|
-
): Promise<{ data: any; error: string | null }> {
|
|
175
|
-
const { data, error } = await supabase
|
|
176
|
-
.from('bookmarks')
|
|
177
|
-
.select('*')
|
|
178
|
-
.eq('id', id)
|
|
179
|
-
.single()
|
|
180
|
-
|
|
181
|
-
if (error) return { data: null, error: error.code === 'PGRST116' ? 'Bookmark not found' : error.message }
|
|
182
|
-
|
|
183
|
-
if (expectedStatus) {
|
|
184
|
-
const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus]
|
|
185
|
-
if (!allowed.includes(data.status)) {
|
|
186
|
-
const statusLabels: Record<string, string> = { active: 'Flame', read: 'Spark', absorbed: 'Vault', ash: 'Ash' }
|
|
187
|
-
return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map(s => statusLabels[s] || s).join(' or ')})` }
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return { data, error: null }
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
// Helper: merge fields into content_metadata JSONB without overwriting
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
|
|
198
|
-
async function mergeContentMetadata(
|
|
199
|
-
bookmarkId: string,
|
|
200
|
-
fields: Record<string, unknown>,
|
|
201
|
-
extraColumns?: Record<string, unknown>
|
|
202
|
-
): Promise<{ error: string | null }> {
|
|
203
|
-
const { data, error } = await supabase
|
|
204
|
-
.from('bookmarks')
|
|
205
|
-
.select('content_metadata')
|
|
206
|
-
.eq('id', bookmarkId)
|
|
207
|
-
.single()
|
|
208
|
-
|
|
209
|
-
if (error) return { error: error.message }
|
|
210
|
-
|
|
211
|
-
const existing = (data.content_metadata || {}) as Record<string, unknown>
|
|
212
|
-
// Only merge non-undefined fields
|
|
213
|
-
const cleaned: Record<string, unknown> = {}
|
|
214
|
-
for (const [k, v] of Object.entries(fields)) {
|
|
215
|
-
if (v !== undefined && v !== null) cleaned[k] = v
|
|
216
|
-
}
|
|
217
|
-
const merged = { ...existing, ...cleaned }
|
|
218
|
-
|
|
219
|
-
const { error: updateError } = await supabase
|
|
220
|
-
.from('bookmarks')
|
|
221
|
-
.update({ content_metadata: merged, ...extraColumns })
|
|
222
|
-
.eq('id', bookmarkId)
|
|
223
|
-
|
|
224
|
-
return { error: updateError?.message || null }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
// Helper: extract fields from content_metadata JSONB
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
|
|
231
|
-
function meta(row: any): any {
|
|
232
|
-
const m = row.content_metadata || {}
|
|
233
|
-
return {
|
|
234
|
-
id: row.id,
|
|
235
|
-
url: row.url,
|
|
236
|
-
title: row.title,
|
|
237
|
-
author: m.author || row.author || null,
|
|
238
|
-
platform: row.platform,
|
|
239
|
-
status: row.status,
|
|
240
|
-
tags: m.tags || [],
|
|
241
|
-
thumbnail: m.thumbnail || null,
|
|
242
|
-
vaultCategory: m.vault_category || null,
|
|
243
|
-
vaultedAt: m.vaulted_at || null,
|
|
244
|
-
aiPositioning: m.ai_positioning || null,
|
|
245
|
-
aiDensity: m.ai_density || null,
|
|
246
|
-
aiMinutes: m.ai_minutes || null,
|
|
247
|
-
aiTakeaway: m.ai_takeaway || [],
|
|
248
|
-
aiStrategyReason: m.ai_strategy_reason || null,
|
|
249
|
-
aiHowToRead: m.ai_how_to_read || null,
|
|
250
|
-
aiOverlap: m.ai_overlap || null,
|
|
251
|
-
aiVerdict: m.ai_verdict || null,
|
|
252
|
-
aiSummary: m.ai_summary || null,
|
|
253
|
-
sparkInsight: m.spark_insight || null,
|
|
254
|
-
extractedContent: m.extracted_content || null,
|
|
255
|
-
externalURL: m.external_url || null,
|
|
256
|
-
aiRelevance: m.ai_relevance || null,
|
|
257
|
-
aiNovelty: m.ai_novelty || null,
|
|
258
|
-
createdAt: row.created_at,
|
|
259
|
-
countdownExpiresAt: row.countdown_expires_at,
|
|
260
|
-
readAt: row.read_at,
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Compact summary for list views (no extracted content) */
|
|
265
|
-
function metaSummary(row: any): any {
|
|
266
|
-
const m = row.content_metadata || {}
|
|
267
|
-
return {
|
|
268
|
-
id: row.id,
|
|
269
|
-
url: row.url,
|
|
270
|
-
title: row.title,
|
|
271
|
-
author: m.author || null,
|
|
272
|
-
platform: row.platform,
|
|
273
|
-
tags: m.tags || [],
|
|
274
|
-
vaultCategory: m.vault_category || null,
|
|
275
|
-
vaultedAt: m.vaulted_at || null,
|
|
276
|
-
aiPositioning: m.ai_positioning || null,
|
|
277
|
-
aiTakeaway: m.ai_takeaway || [],
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Flame-specific summary with countdown and AI triage fields */
|
|
282
|
-
function flameSummary(row: any): any {
|
|
283
|
-
const m = row.content_metadata || {}
|
|
284
|
-
const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null
|
|
285
|
-
const now = new Date()
|
|
286
|
-
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0
|
|
287
|
-
const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10)
|
|
288
|
-
|
|
289
|
-
return {
|
|
290
|
-
id: row.id,
|
|
291
|
-
url: row.url,
|
|
292
|
-
title: row.title,
|
|
293
|
-
author: m.author || null,
|
|
294
|
-
platform: row.platform,
|
|
295
|
-
tags: m.tags || [],
|
|
296
|
-
createdAt: row.created_at,
|
|
297
|
-
expiresAt: row.countdown_expires_at,
|
|
298
|
-
remainingHours,
|
|
299
|
-
isBurning: remainingHours <= 6,
|
|
300
|
-
isCritical: remainingHours <= 1,
|
|
301
|
-
aiPositioning: m.ai_positioning || null,
|
|
302
|
-
aiDensity: m.ai_density || null,
|
|
303
|
-
aiMinutes: m.ai_minutes || null,
|
|
304
|
-
aiTakeaway: m.ai_takeaway || [],
|
|
305
|
-
aiStrategy: m.ai_strategy || null,
|
|
306
|
-
aiStrategyReason: m.ai_strategy_reason || null,
|
|
307
|
-
aiHowToRead: m.ai_how_to_read || null,
|
|
308
|
-
aiRelevance: m.ai_relevance || null,
|
|
309
|
-
aiNovelty: m.ai_novelty || null,
|
|
310
|
-
aiOverlap: m.ai_overlap || null,
|
|
311
|
-
aiHook: m.ai_hook || null,
|
|
312
|
-
aiAbout: m.ai_about || [],
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// Tool handlers (all wrapped with rate limiting)
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
/** Wrap a handler with rate limiting */
|
|
321
|
-
function rateLimited<T>(handler: (args: T) => Promise<{ content: { type: 'text'; text: string }[] }>) {
|
|
322
|
-
return async (args: T) => {
|
|
323
|
-
const err = checkRateLimit()
|
|
324
|
-
if (err) return textResult(err)
|
|
325
|
-
return handler(args)
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function handleSearchVault(args: { query: string; limit?: number }) {
|
|
330
|
-
const { query, limit } = args
|
|
331
|
-
const { data, error } = await supabase
|
|
332
|
-
.from('bookmarks')
|
|
333
|
-
.select('*')
|
|
334
|
-
.eq('status', 'absorbed')
|
|
335
|
-
.ilike('title', `%${query}%`)
|
|
336
|
-
.order('created_at', { ascending: false })
|
|
337
|
-
.limit(limit || 10)
|
|
338
|
-
|
|
339
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
340
|
-
|
|
341
|
-
// Also search in content_metadata tags and takeaway
|
|
342
|
-
let results = (data || []).map(metaSummary)
|
|
343
|
-
|
|
344
|
-
// If title search returned few results, also search by tag
|
|
345
|
-
if (results.length < (limit || 10)) {
|
|
346
|
-
const { data: tagData } = await supabase
|
|
347
|
-
.from('bookmarks')
|
|
348
|
-
.select('*')
|
|
349
|
-
.eq('status', 'absorbed')
|
|
350
|
-
.order('created_at', { ascending: false })
|
|
351
|
-
.limit(50)
|
|
352
|
-
|
|
353
|
-
if (tagData) {
|
|
354
|
-
const existingIds = new Set(results.map((r: any) => r.id))
|
|
355
|
-
const tagMatches = tagData
|
|
356
|
-
.filter((row: any) => {
|
|
357
|
-
if (existingIds.has(row.id)) return false
|
|
358
|
-
const m = row.content_metadata || {}
|
|
359
|
-
const tags = (m.tags || []) as string[]
|
|
360
|
-
const takeaway = (m.ai_takeaway || []) as string[]
|
|
361
|
-
const positioning = m.ai_positioning || ''
|
|
362
|
-
const allText = [...tags, ...takeaway, positioning].join(' ').toLowerCase()
|
|
363
|
-
return allText.includes(query.toLowerCase())
|
|
364
|
-
})
|
|
365
|
-
.map(metaSummary)
|
|
366
|
-
|
|
367
|
-
results = [...results, ...tagMatches].slice(0, limit || 10)
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return textResult(JSON.stringify(results, null, 2))
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function handleGetBookmark(args: { id: string }) {
|
|
375
|
-
const { data, error } = await supabase
|
|
376
|
-
.from('bookmarks')
|
|
377
|
-
.select('*')
|
|
378
|
-
.eq('id', args.id)
|
|
379
|
-
.single()
|
|
380
|
-
|
|
381
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
382
|
-
return textResult(JSON.stringify(meta(data), null, 2))
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async function handleListCategories() {
|
|
386
|
-
const { data, error } = await supabase
|
|
387
|
-
.from('bookmarks')
|
|
388
|
-
.select('content_metadata')
|
|
389
|
-
.eq('status', 'absorbed')
|
|
390
|
-
|
|
391
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
392
|
-
|
|
393
|
-
const counts: Record<string, number> = {}
|
|
394
|
-
for (const row of data || []) {
|
|
395
|
-
const cat = (row.content_metadata as any)?.vault_category || 'Uncategorized'
|
|
396
|
-
counts[cat] = (counts[cat] || 0) + 1
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const categories = Object.entries(counts)
|
|
400
|
-
.map(([category, count]) => ({ category, count }))
|
|
401
|
-
.sort((a, b) => b.count - a.count)
|
|
402
|
-
|
|
403
|
-
return textResult(JSON.stringify(categories, null, 2))
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function handleGetCollections() {
|
|
407
|
-
const { data, error } = await supabase
|
|
408
|
-
.from('collections')
|
|
409
|
-
.select('id, name, bookmark_ids, ai_overview')
|
|
410
|
-
|
|
411
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
412
|
-
|
|
413
|
-
const collections = (data || []).map((c: any) => ({
|
|
414
|
-
id: c.id,
|
|
415
|
-
name: c.name,
|
|
416
|
-
articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
|
|
417
|
-
overview: c.ai_overview?.theme || null,
|
|
418
|
-
}))
|
|
419
|
-
|
|
420
|
-
return textResult(JSON.stringify(collections, null, 2))
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function handleGetCollectionOverview(args: { name: string }) {
|
|
424
|
-
const { data: collection, error } = await supabase
|
|
425
|
-
.from('collections')
|
|
426
|
-
.select('*')
|
|
427
|
-
.eq('name', args.name)
|
|
428
|
-
.single()
|
|
429
|
-
|
|
430
|
-
if (error) {
|
|
431
|
-
return textResult(
|
|
432
|
-
error.code === 'PGRST116'
|
|
433
|
-
? `No collection found with name "${args.name}".`
|
|
434
|
-
: `Error: ${error.message}`
|
|
435
|
-
)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
let bookmarks: any[] = []
|
|
439
|
-
if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
|
|
440
|
-
const { data: bData, error: bError } = await supabase
|
|
441
|
-
.from('bookmarks')
|
|
442
|
-
.select('*')
|
|
443
|
-
.in('id', collection.bookmark_ids)
|
|
444
|
-
|
|
445
|
-
if (!bError && bData) {
|
|
446
|
-
bookmarks = bData.map(metaSummary)
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return textResult(JSON.stringify({
|
|
451
|
-
id: collection.id,
|
|
452
|
-
name: collection.name,
|
|
453
|
-
articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
|
|
454
|
-
aiOverview: collection.ai_overview,
|
|
455
|
-
bookmarks,
|
|
456
|
-
}, null, 2))
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function handleGetArticleContent(args: { id: string }) {
|
|
460
|
-
const { data, error } = await supabase
|
|
461
|
-
.from('bookmarks')
|
|
462
|
-
.select('*')
|
|
463
|
-
.eq('id', args.id)
|
|
464
|
-
.single()
|
|
465
|
-
|
|
466
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
467
|
-
return textResult(JSON.stringify(meta(data), null, 2))
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ---------------------------------------------------------------------------
|
|
471
|
-
// Vercel API base URL for content fetching
|
|
472
|
-
// ---------------------------------------------------------------------------
|
|
473
|
-
|
|
474
|
-
const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud'
|
|
475
|
-
const API_KEY = process.env.BURN_API_KEY
|
|
476
|
-
|
|
477
|
-
/** Detect platform from URL */
|
|
478
|
-
function detectPlatform(url: string): string {
|
|
479
|
-
if (/x\.com|twitter\.com/i.test(url)) return 'x'
|
|
480
|
-
if (/youtube\.com|youtu\.be/i.test(url)) return 'youtube'
|
|
481
|
-
if (/reddit\.com|redd\.it/i.test(url)) return 'reddit'
|
|
482
|
-
if (/bilibili\.com|b23\.tv/i.test(url)) return 'bilibili'
|
|
483
|
-
if (/open\.spotify\.com/i.test(url)) return 'spotify'
|
|
484
|
-
if (/mp\.weixin\.qq\.com/i.test(url)) return 'wechat'
|
|
485
|
-
if (/xiaohongshu\.com|xhslink\.com/i.test(url)) return 'xhs'
|
|
486
|
-
return 'web'
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** Fetch content via Vercel API proxy (bypasses GFW for X.com, etc.) */
|
|
490
|
-
async function fetchViaAPI(url: string, platform: string): Promise<{ title?: string; author?: string; content?: string; error?: string }> {
|
|
491
|
-
try {
|
|
492
|
-
let endpoint: string
|
|
493
|
-
let params: string
|
|
494
|
-
|
|
495
|
-
switch (platform) {
|
|
496
|
-
case 'x':
|
|
497
|
-
endpoint = `${API_BASE}/api/parse-x`
|
|
498
|
-
params = `url=${encodeURIComponent(url)}`
|
|
499
|
-
break
|
|
500
|
-
case 'reddit':
|
|
501
|
-
endpoint = `${API_BASE}/api/parse-reddit`
|
|
502
|
-
params = `url=${encodeURIComponent(url)}`
|
|
503
|
-
break
|
|
504
|
-
case 'spotify':
|
|
505
|
-
endpoint = `${API_BASE}/api/parse-meta`
|
|
506
|
-
params = `url=${encodeURIComponent(url)}&_platform=spotify`
|
|
507
|
-
break
|
|
508
|
-
case 'wechat':
|
|
509
|
-
case 'xhs':
|
|
510
|
-
endpoint = `${API_BASE}/api/parse-meta`
|
|
511
|
-
params = `url=${encodeURIComponent(url)}&_platform=${platform}`
|
|
512
|
-
break
|
|
513
|
-
case 'youtube':
|
|
514
|
-
// Try transcript extraction via jina-extract with youtube platform hint
|
|
515
|
-
endpoint = `${API_BASE}/api/jina-extract`
|
|
516
|
-
params = `url=${encodeURIComponent(url)}&_platform=youtube`
|
|
517
|
-
break
|
|
518
|
-
default:
|
|
519
|
-
endpoint = `${API_BASE}/api/jina-extract`
|
|
520
|
-
params = `url=${encodeURIComponent(url)}`
|
|
521
|
-
break
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const resp = await fetch(`${endpoint}?${params}`, {
|
|
525
|
-
headers: {
|
|
526
|
-
...(API_KEY ? { 'x-api-key': API_KEY } : {}),
|
|
527
|
-
'Accept': 'application/json',
|
|
528
|
-
},
|
|
529
|
-
signal: AbortSignal.timeout(30000),
|
|
530
|
-
})
|
|
531
|
-
|
|
532
|
-
if (!resp.ok) {
|
|
533
|
-
return { error: `API returned ${resp.status}` }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const data = await resp.json() as any
|
|
537
|
-
|
|
538
|
-
// Normalize response across different API endpoints
|
|
539
|
-
if (platform === 'x') {
|
|
540
|
-
const text = data.text || data.article_text || ''
|
|
541
|
-
const quoteText = data.quote ? `\n\n[Quote from @${data.quote.handle}]: ${data.quote.text}` : ''
|
|
542
|
-
return {
|
|
543
|
-
title: data.text?.slice(0, 100) || 'Tweet',
|
|
544
|
-
author: data.author ? `@${data.handle || data.author}` : undefined,
|
|
545
|
-
content: text + quoteText,
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (platform === 'spotify') {
|
|
550
|
-
return {
|
|
551
|
-
title: data.title,
|
|
552
|
-
author: data.author,
|
|
553
|
-
content: data.extracted_content || data.description || null,
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (platform === 'wechat') {
|
|
558
|
-
// WeChat parse-meta returns extracted_content from js_content div
|
|
559
|
-
return {
|
|
560
|
-
title: data.title,
|
|
561
|
-
author: data.author,
|
|
562
|
-
content: data.extracted_content || data.content || null,
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
return {
|
|
567
|
-
title: data.title,
|
|
568
|
-
author: data.author,
|
|
569
|
-
content: data.content || data.extracted_content || data.text || data.transcript || null,
|
|
570
|
-
}
|
|
571
|
-
} catch (err: any) {
|
|
572
|
-
return { error: err.message || 'Fetch failed' }
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async function handleFetchContent(args: { url: string }) {
|
|
577
|
-
const { url } = args
|
|
578
|
-
const platform = detectPlatform(url)
|
|
579
|
-
|
|
580
|
-
// Step 1: Check if we already have content in Supabase
|
|
581
|
-
const { data: existing } = await supabase
|
|
582
|
-
.from('bookmarks')
|
|
583
|
-
.select('*')
|
|
584
|
-
.eq('url', url)
|
|
585
|
-
.limit(1)
|
|
586
|
-
.maybeSingle()
|
|
587
|
-
|
|
588
|
-
if (existing) {
|
|
589
|
-
const m = existing.content_metadata || {}
|
|
590
|
-
if (m.extracted_content && m.extracted_content.length > 50) {
|
|
591
|
-
return textResult(JSON.stringify({
|
|
592
|
-
source: 'cache',
|
|
593
|
-
url,
|
|
594
|
-
platform,
|
|
595
|
-
title: existing.title,
|
|
596
|
-
author: m.author,
|
|
597
|
-
content: m.extracted_content,
|
|
598
|
-
aiPositioning: m.ai_positioning,
|
|
599
|
-
aiTakeaway: m.ai_takeaway,
|
|
600
|
-
tags: m.tags,
|
|
601
|
-
}, null, 2))
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Step 2: Fetch fresh content via Vercel API
|
|
606
|
-
const result = await fetchViaAPI(url, platform)
|
|
607
|
-
|
|
608
|
-
if (result.error) {
|
|
609
|
-
return textResult(JSON.stringify({
|
|
610
|
-
source: 'error',
|
|
611
|
-
url,
|
|
612
|
-
platform,
|
|
613
|
-
error: result.error,
|
|
614
|
-
hint: platform === 'x' ? 'X.com content is fetched via Vercel Edge proxy to bypass GFW' : undefined,
|
|
615
|
-
}, null, 2))
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return textResult(JSON.stringify({
|
|
619
|
-
source: 'live',
|
|
620
|
-
url,
|
|
621
|
-
platform,
|
|
622
|
-
title: result.title,
|
|
623
|
-
author: result.author,
|
|
624
|
-
content: result.content,
|
|
625
|
-
}, null, 2))
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
async function handleListSparks(args: { limit?: number }) {
|
|
629
|
-
const { data, error } = await supabase
|
|
630
|
-
.from('bookmarks')
|
|
631
|
-
.select('*')
|
|
632
|
-
.eq('status', 'read')
|
|
633
|
-
.order('created_at', { ascending: false })
|
|
634
|
-
.limit(args.limit || 20)
|
|
635
|
-
|
|
636
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
637
|
-
|
|
638
|
-
const results = (data || []).map((row: any) => {
|
|
639
|
-
const s = metaSummary(row)
|
|
640
|
-
const m = row.content_metadata || {}
|
|
641
|
-
return {
|
|
642
|
-
...s,
|
|
643
|
-
sparkInsight: m.spark_insight || null,
|
|
644
|
-
sparkExpiresAt: m.spark_expires_at || null,
|
|
645
|
-
}
|
|
23
|
+
async function main() {
|
|
24
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
25
|
+
auth: { persistSession: false, autoRefreshToken: true },
|
|
26
|
+
...(LEGACY_JWT ? { global: { headers: { Authorization: `Bearer ${LEGACY_JWT}` } } } : {}),
|
|
646
27
|
})
|
|
647
28
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
661
|
-
|
|
662
|
-
const results = (data || [])
|
|
663
|
-
.filter((row: any) => {
|
|
664
|
-
const m = row.content_metadata || {}
|
|
665
|
-
const searchable = [
|
|
666
|
-
row.title || '',
|
|
667
|
-
...(m.tags || []),
|
|
668
|
-
...(m.ai_takeaway || []),
|
|
669
|
-
m.ai_positioning || '',
|
|
670
|
-
m.spark_insight || '',
|
|
671
|
-
].join(' ').toLowerCase()
|
|
672
|
-
return searchable.includes(query.toLowerCase())
|
|
673
|
-
})
|
|
674
|
-
.slice(0, limit || 10)
|
|
675
|
-
.map((row: any) => {
|
|
676
|
-
const s = metaSummary(row)
|
|
677
|
-
const m = row.content_metadata || {}
|
|
678
|
-
return {
|
|
679
|
-
...s,
|
|
680
|
-
sparkInsight: m.spark_insight || null,
|
|
681
|
-
sparkExpiresAt: m.spark_expires_at || null,
|
|
29
|
+
if (!LEGACY_JWT && MCP_TOKEN) {
|
|
30
|
+
const cached = loadCachedSession()
|
|
31
|
+
if (cached) {
|
|
32
|
+
try {
|
|
33
|
+
await applySession(supabase, cached, s => saveCachedSession(s))
|
|
34
|
+
console.error('Burn MCP: restored session from cache')
|
|
35
|
+
} catch {
|
|
36
|
+
console.error('Burn MCP: cached session expired, re-exchanging...')
|
|
37
|
+
const fresh = await exchangeToken(MCP_TOKEN)
|
|
38
|
+
await applySession(supabase, fresh, s => saveCachedSession(s))
|
|
39
|
+
saveCachedSession(fresh)
|
|
40
|
+
console.error('Burn MCP: session exchanged and cached')
|
|
682
41
|
}
|
|
683
|
-
})
|
|
684
|
-
|
|
685
|
-
return textResult(JSON.stringify(results, null, 2))
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
async function handleListFlame(args: { limit?: number }) {
|
|
689
|
-
const { data, error } = await supabase
|
|
690
|
-
.from('bookmarks')
|
|
691
|
-
.select('*')
|
|
692
|
-
.eq('status', 'active')
|
|
693
|
-
.order('created_at', { ascending: false })
|
|
694
|
-
.limit(args.limit || 20)
|
|
695
|
-
|
|
696
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
697
|
-
|
|
698
|
-
// Filter out already expired ones (should be ash but not yet processed)
|
|
699
|
-
const now = new Date()
|
|
700
|
-
const results = (data || [])
|
|
701
|
-
.filter((row: any) => {
|
|
702
|
-
if (!row.countdown_expires_at) return true
|
|
703
|
-
return new Date(row.countdown_expires_at).getTime() > now.getTime()
|
|
704
|
-
})
|
|
705
|
-
.map(flameSummary)
|
|
706
|
-
|
|
707
|
-
return textResult(JSON.stringify(results, null, 2))
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
async function handleGetFlameDetail(args: { id: string }) {
|
|
711
|
-
const { data, error } = await supabase
|
|
712
|
-
.from('bookmarks')
|
|
713
|
-
.select('*')
|
|
714
|
-
.eq('id', args.id)
|
|
715
|
-
.eq('status', 'active')
|
|
716
|
-
.single()
|
|
717
|
-
|
|
718
|
-
if (error) {
|
|
719
|
-
return textResult(
|
|
720
|
-
error.code === 'PGRST116'
|
|
721
|
-
? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.`
|
|
722
|
-
: `Error: ${error.message}`
|
|
723
|
-
)
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Return full detail including extracted content
|
|
727
|
-
const m = data.content_metadata || {}
|
|
728
|
-
const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null
|
|
729
|
-
const now = new Date()
|
|
730
|
-
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0
|
|
731
|
-
const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10)
|
|
732
|
-
|
|
733
|
-
const result = {
|
|
734
|
-
...flameSummary(data),
|
|
735
|
-
extractedContent: m.extracted_content || null,
|
|
736
|
-
externalURL: m.external_url || null,
|
|
737
|
-
thumbnail: m.thumbnail || null,
|
|
738
|
-
aiFocus: m.ai_focus || null,
|
|
739
|
-
aiUse: m.ai_use || null,
|
|
740
|
-
aiBuzz: m.ai_buzz || null,
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
return textResult(JSON.stringify(result, null, 2))
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
async function handleListVault(args: { limit?: number; category?: string }) {
|
|
747
|
-
let query = supabase
|
|
748
|
-
.from('bookmarks')
|
|
749
|
-
.select('*')
|
|
750
|
-
.eq('status', 'absorbed')
|
|
751
|
-
.order('created_at', { ascending: false })
|
|
752
|
-
.limit(args.limit || 20)
|
|
753
|
-
|
|
754
|
-
const { data, error } = await query
|
|
755
|
-
|
|
756
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
757
|
-
|
|
758
|
-
let results = (data || []).map(metaSummary)
|
|
759
|
-
|
|
760
|
-
// Filter by category if provided
|
|
761
|
-
if (args.category) {
|
|
762
|
-
results = results.filter((r: any) =>
|
|
763
|
-
r.vaultCategory?.toLowerCase() === args.category!.toLowerCase()
|
|
764
|
-
)
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
return textResult(JSON.stringify(results, null, 2))
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// ---------------------------------------------------------------------------
|
|
771
|
-
// Layer 1: Status flow handlers (决策层)
|
|
772
|
-
// ---------------------------------------------------------------------------
|
|
773
|
-
|
|
774
|
-
async function handleMoveFlameToSpark(args: { id: string; spark_insight?: string }) {
|
|
775
|
-
const { data, error } = await verifyBookmark(args.id, 'active')
|
|
776
|
-
if (error) return textResult(`Error: ${error}`)
|
|
777
|
-
|
|
778
|
-
const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
779
|
-
const metaFields: Record<string, unknown> = { spark_expires_at: sparkExpiresAt }
|
|
780
|
-
if (args.spark_insight) metaFields.spark_insight = args.spark_insight
|
|
781
|
-
|
|
782
|
-
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
783
|
-
status: 'read',
|
|
784
|
-
read_at: new Date().toISOString(),
|
|
785
|
-
})
|
|
786
|
-
if (mergeErr) return textResult(`Error: ${mergeErr}`)
|
|
787
|
-
|
|
788
|
-
return textResult(JSON.stringify({
|
|
789
|
-
success: true,
|
|
790
|
-
id: args.id,
|
|
791
|
-
title: data.title,
|
|
792
|
-
action: 'flame → spark',
|
|
793
|
-
sparkExpiresAt,
|
|
794
|
-
}, null, 2))
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
async function handleMoveFlameToAsh(args: { id: string; reason?: string }) {
|
|
798
|
-
const { data, error } = await verifyBookmark(args.id, 'active')
|
|
799
|
-
if (error) return textResult(`Error: ${error}`)
|
|
800
|
-
|
|
801
|
-
const { error: updateErr } = await supabase
|
|
802
|
-
.from('bookmarks')
|
|
803
|
-
.update({ status: 'ash' })
|
|
804
|
-
.eq('id', args.id)
|
|
805
|
-
|
|
806
|
-
if (updateErr) return textResult(`Error: ${updateErr.message}`)
|
|
807
|
-
|
|
808
|
-
return textResult(JSON.stringify({
|
|
809
|
-
success: true,
|
|
810
|
-
id: args.id,
|
|
811
|
-
title: data.title,
|
|
812
|
-
action: 'flame → ash',
|
|
813
|
-
reason: args.reason || null,
|
|
814
|
-
}, null, 2))
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
async function handleMoveSparkToVault(args: { id: string; vault_category?: string }) {
|
|
818
|
-
const { data, error } = await verifyBookmark(args.id, 'read')
|
|
819
|
-
if (error) return textResult(`Error: ${error}`)
|
|
820
|
-
|
|
821
|
-
const metaFields: Record<string, unknown> = { vaulted_at: new Date().toISOString() }
|
|
822
|
-
if (args.vault_category) metaFields.vault_category = args.vault_category
|
|
823
|
-
|
|
824
|
-
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
825
|
-
status: 'absorbed',
|
|
826
|
-
})
|
|
827
|
-
if (mergeErr) return textResult(`Error: ${mergeErr}`)
|
|
828
|
-
|
|
829
|
-
return textResult(JSON.stringify({
|
|
830
|
-
success: true,
|
|
831
|
-
id: args.id,
|
|
832
|
-
title: data.title,
|
|
833
|
-
action: 'spark → vault',
|
|
834
|
-
vaultCategory: args.vault_category || null,
|
|
835
|
-
}, null, 2))
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
async function handleMoveSparkToAsh(args: { id: string }) {
|
|
839
|
-
const { data, error } = await verifyBookmark(args.id, 'read')
|
|
840
|
-
if (error) return textResult(`Error: ${error}`)
|
|
841
|
-
|
|
842
|
-
const { error: updateErr } = await supabase
|
|
843
|
-
.from('bookmarks')
|
|
844
|
-
.update({ status: 'ash' })
|
|
845
|
-
.eq('id', args.id)
|
|
846
|
-
|
|
847
|
-
if (updateErr) return textResult(`Error: ${updateErr.message}`)
|
|
848
|
-
|
|
849
|
-
return textResult(JSON.stringify({
|
|
850
|
-
success: true,
|
|
851
|
-
id: args.id,
|
|
852
|
-
title: data.title,
|
|
853
|
-
action: 'spark → ash',
|
|
854
|
-
}, null, 2))
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async function handleBatchTriageFlame(args: { decisions: Array<{ id: string; action: 'spark' | 'ash'; spark_insight?: string }> }) {
|
|
858
|
-
const results: Array<{ id: string; action: string; success: boolean; error?: string; title?: string }> = []
|
|
859
|
-
|
|
860
|
-
for (const decision of args.decisions) {
|
|
861
|
-
if (decision.action === 'spark') {
|
|
862
|
-
const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight })
|
|
863
|
-
const parsed = JSON.parse(res.content[0].text)
|
|
864
|
-
results.push({ id: decision.id, action: 'flame → spark', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title })
|
|
865
42
|
} else {
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
43
|
+
const fresh = await exchangeToken(MCP_TOKEN)
|
|
44
|
+
await applySession(supabase, fresh, s => saveCachedSession(s))
|
|
45
|
+
saveCachedSession(fresh)
|
|
46
|
+
console.error('Burn MCP: session exchanged and cached')
|
|
869
47
|
}
|
|
870
48
|
}
|
|
871
49
|
|
|
872
|
-
const
|
|
873
|
-
const failed = results.filter(r => !r.success).length
|
|
874
|
-
|
|
875
|
-
return textResult(JSON.stringify({
|
|
876
|
-
summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
|
|
877
|
-
results,
|
|
878
|
-
}, null, 2))
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// ---------------------------------------------------------------------------
|
|
882
|
-
// Layer 3: AI analysis writeback handler (分析层)
|
|
883
|
-
// ---------------------------------------------------------------------------
|
|
884
|
-
|
|
885
|
-
async function handleWriteBookmarkAnalysis(args: {
|
|
886
|
-
id: string
|
|
887
|
-
analysis: {
|
|
888
|
-
ai_summary?: string
|
|
889
|
-
ai_strategy?: string
|
|
890
|
-
ai_strategy_reason?: string
|
|
891
|
-
ai_minutes?: number
|
|
892
|
-
ai_takeaway?: string[]
|
|
893
|
-
ai_relevance?: number
|
|
894
|
-
ai_novelty?: number
|
|
895
|
-
tags?: string[]
|
|
896
|
-
}
|
|
897
|
-
}) {
|
|
898
|
-
const { data, error } = await verifyBookmark(args.id)
|
|
899
|
-
if (error) return textResult(`Error: ${error}`)
|
|
900
|
-
|
|
901
|
-
const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis)
|
|
902
|
-
if (mergeErr) return textResult(`Error: ${mergeErr}`)
|
|
903
|
-
|
|
904
|
-
const fieldsWritten = Object.keys(args.analysis).filter(k => (args.analysis as any)[k] !== undefined)
|
|
905
|
-
|
|
906
|
-
return textResult(JSON.stringify({
|
|
907
|
-
success: true,
|
|
908
|
-
id: args.id,
|
|
909
|
-
title: data.title,
|
|
910
|
-
fieldsWritten,
|
|
911
|
-
}, null, 2))
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// ---------------------------------------------------------------------------
|
|
915
|
-
// Layer 2: Collection handlers (组合层)
|
|
916
|
-
// ---------------------------------------------------------------------------
|
|
917
|
-
|
|
918
|
-
async function handleCreateCollection(args: { name: string; bookmark_ids?: string[] }) {
|
|
919
|
-
// Get user ID from an existing bookmark (RLS ensures we only see our own)
|
|
920
|
-
const { data: sample } = await supabase
|
|
921
|
-
.from('bookmarks')
|
|
922
|
-
.select('user_id')
|
|
923
|
-
.limit(1)
|
|
924
|
-
.single()
|
|
925
|
-
|
|
926
|
-
if (!sample) return textResult('Error: No bookmarks found — cannot determine user ID')
|
|
927
|
-
|
|
928
|
-
const bookmarkIds = args.bookmark_ids || []
|
|
929
|
-
|
|
930
|
-
// Verify bookmark_ids exist if provided
|
|
931
|
-
if (bookmarkIds.length > 0) {
|
|
932
|
-
const { data: existing } = await supabase
|
|
933
|
-
.from('bookmarks')
|
|
934
|
-
.select('id')
|
|
935
|
-
.in('id', bookmarkIds)
|
|
936
|
-
|
|
937
|
-
const existingIds = new Set((existing || []).map((b: any) => b.id))
|
|
938
|
-
const missing = bookmarkIds.filter(id => !existingIds.has(id))
|
|
939
|
-
if (missing.length > 0) {
|
|
940
|
-
return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`)
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
const { data, error } = await supabase
|
|
945
|
-
.from('collections')
|
|
946
|
-
.insert({
|
|
947
|
-
user_id: sample.user_id,
|
|
948
|
-
name: args.name,
|
|
949
|
-
bookmark_ids: bookmarkIds,
|
|
950
|
-
is_overview_stale: true,
|
|
951
|
-
})
|
|
952
|
-
.select()
|
|
953
|
-
.single()
|
|
954
|
-
|
|
955
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
956
|
-
|
|
957
|
-
return textResult(JSON.stringify({
|
|
958
|
-
success: true,
|
|
959
|
-
id: data.id,
|
|
960
|
-
name: data.name,
|
|
961
|
-
articleCount: bookmarkIds.length,
|
|
962
|
-
}, null, 2))
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
async function handleAddToCollection(args: { collection_id: string; bookmark_ids: string[] }) {
|
|
966
|
-
const { data: collection, error } = await supabase
|
|
967
|
-
.from('collections')
|
|
968
|
-
.select('*')
|
|
969
|
-
.eq('id', args.collection_id)
|
|
970
|
-
.single()
|
|
971
|
-
|
|
972
|
-
if (error) {
|
|
973
|
-
return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// Verify bookmark_ids exist
|
|
977
|
-
const { data: existing } = await supabase
|
|
978
|
-
.from('bookmarks')
|
|
979
|
-
.select('id')
|
|
980
|
-
.in('id', args.bookmark_ids)
|
|
981
|
-
|
|
982
|
-
const existingIds = new Set((existing || []).map((b: any) => b.id))
|
|
983
|
-
const missing = args.bookmark_ids.filter(id => !existingIds.has(id))
|
|
984
|
-
if (missing.length > 0) {
|
|
985
|
-
return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`)
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
// Union with existing (deduplicate)
|
|
989
|
-
const currentIds = new Set(collection.bookmark_ids || [])
|
|
990
|
-
const newIds = args.bookmark_ids.filter(id => !currentIds.has(id))
|
|
991
|
-
const merged = [...(collection.bookmark_ids || []), ...newIds]
|
|
992
|
-
|
|
993
|
-
const { error: updateErr } = await supabase
|
|
994
|
-
.from('collections')
|
|
995
|
-
.update({ bookmark_ids: merged, is_overview_stale: true })
|
|
996
|
-
.eq('id', args.collection_id)
|
|
997
|
-
|
|
998
|
-
if (updateErr) return textResult(`Error: ${updateErr.message}`)
|
|
999
|
-
|
|
1000
|
-
return textResult(JSON.stringify({
|
|
1001
|
-
success: true,
|
|
1002
|
-
collectionId: args.collection_id,
|
|
1003
|
-
name: collection.name,
|
|
1004
|
-
added: newIds.length,
|
|
1005
|
-
alreadyPresent: args.bookmark_ids.length - newIds.length,
|
|
1006
|
-
totalArticles: merged.length,
|
|
1007
|
-
}, null, 2))
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
async function handleRemoveFromCollection(args: { collection_id: string; bookmark_ids: string[] }) {
|
|
1011
|
-
const { data: collection, error } = await supabase
|
|
1012
|
-
.from('collections')
|
|
1013
|
-
.select('*')
|
|
1014
|
-
.eq('id', args.collection_id)
|
|
1015
|
-
.single()
|
|
1016
|
-
|
|
1017
|
-
if (error) {
|
|
1018
|
-
return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
const removeSet = new Set(args.bookmark_ids)
|
|
1022
|
-
const filtered = (collection.bookmark_ids || []).filter((id: string) => !removeSet.has(id))
|
|
1023
|
-
const removed = (collection.bookmark_ids || []).length - filtered.length
|
|
1024
|
-
|
|
1025
|
-
const { error: updateErr } = await supabase
|
|
1026
|
-
.from('collections')
|
|
1027
|
-
.update({ bookmark_ids: filtered, is_overview_stale: true })
|
|
1028
|
-
.eq('id', args.collection_id)
|
|
1029
|
-
|
|
1030
|
-
if (updateErr) return textResult(`Error: ${updateErr.message}`)
|
|
1031
|
-
|
|
1032
|
-
return textResult(JSON.stringify({
|
|
1033
|
-
success: true,
|
|
1034
|
-
collectionId: args.collection_id,
|
|
1035
|
-
name: collection.name,
|
|
1036
|
-
removed,
|
|
1037
|
-
totalArticles: filtered.length,
|
|
1038
|
-
}, null, 2))
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
async function handleUpdateCollectionOverview(args: {
|
|
1042
|
-
collection_id: string
|
|
1043
|
-
overview: {
|
|
1044
|
-
theme: string
|
|
1045
|
-
synthesis?: string
|
|
1046
|
-
patterns?: string[]
|
|
1047
|
-
gaps?: string[]
|
|
1048
|
-
}
|
|
1049
|
-
}) {
|
|
1050
|
-
const { data: collection, error } = await supabase
|
|
1051
|
-
.from('collections')
|
|
1052
|
-
.select('id, name')
|
|
1053
|
-
.eq('id', args.collection_id)
|
|
1054
|
-
.single()
|
|
1055
|
-
|
|
1056
|
-
if (error) {
|
|
1057
|
-
return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const { error: updateErr } = await supabase
|
|
1061
|
-
.from('collections')
|
|
1062
|
-
.update({ ai_overview: args.overview, is_overview_stale: false })
|
|
1063
|
-
.eq('id', args.collection_id)
|
|
1064
|
-
|
|
1065
|
-
if (updateErr) return textResult(`Error: ${updateErr.message}`)
|
|
1066
|
-
|
|
1067
|
-
return textResult(JSON.stringify({
|
|
1068
|
-
success: true,
|
|
1069
|
-
collectionId: args.collection_id,
|
|
1070
|
-
name: collection.name,
|
|
1071
|
-
overviewTheme: args.overview.theme,
|
|
1072
|
-
}, null, 2))
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// ---------------------------------------------------------------------------
|
|
1076
|
-
// Register tools
|
|
1077
|
-
// ---------------------------------------------------------------------------
|
|
1078
|
-
|
|
1079
|
-
// ---------------------------------------------------------------------------
|
|
1080
|
-
// Watched Sources — RSS/Atom parser (no external deps)
|
|
1081
|
-
// ---------------------------------------------------------------------------
|
|
1082
|
-
|
|
1083
|
-
interface FeedItem {
|
|
1084
|
-
url: string
|
|
1085
|
-
title: string
|
|
1086
|
-
author: string
|
|
1087
|
-
publishedAt: string
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
function decodeXMLEntities(str: string): string {
|
|
1091
|
-
return str
|
|
1092
|
-
.replace(/&/g, '&')
|
|
1093
|
-
.replace(/</g, '<')
|
|
1094
|
-
.replace(/>/g, '>')
|
|
1095
|
-
.replace(/"/g, '"')
|
|
1096
|
-
.replace(/'/g, "'")
|
|
1097
|
-
.replace(/'/g, "'")
|
|
1098
|
-
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
function extractXMLValue(block: string, tag: string): string | null {
|
|
1102
|
-
// CDATA
|
|
1103
|
-
const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, 'i')
|
|
1104
|
-
const cdata = block.match(cdataRe)
|
|
1105
|
-
if (cdata) return cdata[1].trim()
|
|
1106
|
-
|
|
1107
|
-
// Atom <link href="..."> self-closing
|
|
1108
|
-
if (tag === 'link') {
|
|
1109
|
-
const href = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*(?:\/>|>)/i)
|
|
1110
|
-
if (href) return href[1].trim()
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// Normal element content
|
|
1114
|
-
const normalRe = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i')
|
|
1115
|
-
const normal = block.match(normalRe)
|
|
1116
|
-
if (normal) return decodeXMLEntities(normal[1].trim())
|
|
1117
|
-
|
|
1118
|
-
return null
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function parseRSSFeed(xml: string): FeedItem[] {
|
|
1122
|
-
const items: FeedItem[] = []
|
|
1123
|
-
const itemRe = /<(?:item|entry)(?: [^>]*)?>([\s\S]*?)<\/(?:item|entry)>/gi
|
|
1124
|
-
let m: RegExpExecArray | null
|
|
1125
|
-
|
|
1126
|
-
while ((m = itemRe.exec(xml)) !== null) {
|
|
1127
|
-
const block = m[1]
|
|
1128
|
-
const title = extractXMLValue(block, 'title') || 'Untitled'
|
|
1129
|
-
const rawUrl = extractXMLValue(block, 'link') || extractXMLValue(block, 'id') || ''
|
|
1130
|
-
if (!rawUrl.startsWith('http')) continue
|
|
1131
|
-
|
|
1132
|
-
// Rewrite nitter domains to x.com so parse-x picks them up correctly
|
|
1133
|
-
const url = rawUrl.replace(/^https?:\/\/nitter\.[^/]+/, 'https://x.com')
|
|
1134
|
-
|
|
1135
|
-
const pubStr = extractXMLValue(block, 'pubDate')
|
|
1136
|
-
|| extractXMLValue(block, 'published')
|
|
1137
|
-
|| extractXMLValue(block, 'updated')
|
|
1138
|
-
|| ''
|
|
1139
|
-
|
|
1140
|
-
let publishedAt = new Date().toISOString()
|
|
1141
|
-
try { if (pubStr) publishedAt = new Date(pubStr).toISOString() } catch { /* keep default */ }
|
|
1142
|
-
|
|
1143
|
-
const author = extractXMLValue(block, 'author') || extractXMLValue(block, 'dc:creator') || ''
|
|
1144
|
-
items.push({ url, title, author, publishedAt })
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
return items
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
async function fetchRSSFeed(feedUrl: string): Promise<FeedItem[]> {
|
|
1151
|
-
const resp = await fetch(feedUrl, {
|
|
1152
|
-
signal: AbortSignal.timeout(12000),
|
|
1153
|
-
headers: {
|
|
1154
|
-
// Use browser UA — many RSS hosts (bearblog, Substack) block bot UAs
|
|
1155
|
-
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
1156
|
-
Accept: 'application/rss+xml, application/atom+xml, text/xml, */*',
|
|
1157
|
-
},
|
|
1158
|
-
})
|
|
1159
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${feedUrl}`)
|
|
1160
|
-
return parseRSSFeed(await resp.text())
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async function getSourceItems(source: any): Promise<FeedItem[]> {
|
|
1164
|
-
const since = source.last_checked_at ? new Date(source.last_checked_at) : new Date(0)
|
|
1165
|
-
|
|
1166
|
-
switch (source.source_type) {
|
|
1167
|
-
case 'x_user': {
|
|
1168
|
-
// All public nitter instances are currently offline (2026).
|
|
1169
|
-
// X/Twitter removed API access for third-party proxies.
|
|
1170
|
-
// Use fetch_content to add individual tweets manually.
|
|
1171
|
-
throw new Error(`X/Twitter timeline scraping is unavailable — public nitter/RSS proxies are offline. To add @${source.handle} tweets, use fetch_content with individual tweet URLs.`)
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
case 'rss': {
|
|
1175
|
-
const items = await fetchRSSFeed(source.handle)
|
|
1176
|
-
return items.filter(i => new Date(i.publishedAt) > since)
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
case 'youtube': {
|
|
1180
|
-
// Accept channel ID (UCxxx) or full channel URL
|
|
1181
|
-
const channelId = source.handle.match(/UC[A-Za-z0-9_-]{21}[AQgw]/)?.[0] || source.handle
|
|
1182
|
-
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`
|
|
1183
|
-
const items = await fetchRSSFeed(rssUrl)
|
|
1184
|
-
return items.filter(i => new Date(i.publishedAt) > since)
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
default:
|
|
1188
|
-
return []
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// ---------------------------------------------------------------------------
|
|
1193
|
-
// Watched Sources — handlers
|
|
1194
|
-
// ---------------------------------------------------------------------------
|
|
1195
|
-
|
|
1196
|
-
async function handleAddWatchedSource(args: { source_type: string; handle: string; name?: string }) {
|
|
1197
|
-
const { data: { user } } = await supabase.auth.getUser()
|
|
1198
|
-
if (!user) return textResult('Error: Not authenticated')
|
|
1199
|
-
|
|
1200
|
-
const { data: existing } = await supabase
|
|
1201
|
-
.from('watched_sources')
|
|
1202
|
-
.select('id, display_name')
|
|
1203
|
-
.eq('user_id', user.id)
|
|
1204
|
-
.eq('handle', args.handle)
|
|
1205
|
-
.eq('active', true)
|
|
1206
|
-
.maybeSingle()
|
|
1207
|
-
|
|
1208
|
-
if (existing) return textResult(`Already watching "${existing.display_name}"`)
|
|
1209
|
-
|
|
1210
|
-
const { data, error } = await supabase
|
|
1211
|
-
.from('watched_sources')
|
|
1212
|
-
.insert({
|
|
1213
|
-
user_id: user.id,
|
|
1214
|
-
source_type: args.source_type,
|
|
1215
|
-
handle: args.handle,
|
|
1216
|
-
display_name: args.name || args.handle,
|
|
1217
|
-
// null = never checked; first scrape will fetch whatever the feed currently contains
|
|
1218
|
-
})
|
|
1219
|
-
.select()
|
|
1220
|
-
.single()
|
|
1221
|
-
|
|
1222
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
1223
|
-
return textResult(JSON.stringify({
|
|
1224
|
-
success: true,
|
|
1225
|
-
id: data.id,
|
|
1226
|
-
message: `Now watching "${data.display_name}" (${data.source_type}). Call scrape_watched_sources to fetch new items.`,
|
|
1227
|
-
}, null, 2))
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
async function handleListWatchedSources() {
|
|
1231
|
-
const { data, error } = await supabase
|
|
1232
|
-
.from('watched_sources')
|
|
1233
|
-
.select('id, source_type, handle, display_name, last_checked_at, created_at')
|
|
1234
|
-
.eq('active', true)
|
|
1235
|
-
.order('created_at', { ascending: false })
|
|
1236
|
-
|
|
1237
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
1238
|
-
if (!data || data.length === 0) return textResult('No watched sources yet. Use add_watched_source to add one.')
|
|
1239
|
-
return textResult(JSON.stringify(data, null, 2))
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
async function handleRemoveWatchedSource(args: { id: string }) {
|
|
1243
|
-
const { error } = await supabase
|
|
1244
|
-
.from('watched_sources')
|
|
1245
|
-
.update({ active: false })
|
|
1246
|
-
.eq('id', args.id)
|
|
1247
|
-
|
|
1248
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
1249
|
-
return textResult('Watched source removed.')
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
async function handleScrapeWatchedSources(args: { source_id?: string }) {
|
|
1253
|
-
const { data: { user } } = await supabase.auth.getUser()
|
|
1254
|
-
if (!user) return textResult('Error: Not authenticated')
|
|
1255
|
-
|
|
1256
|
-
let query = supabase
|
|
1257
|
-
.from('watched_sources')
|
|
1258
|
-
.select('*')
|
|
1259
|
-
.eq('active', true)
|
|
1260
|
-
.eq('user_id', user.id)
|
|
1261
|
-
|
|
1262
|
-
if (args.source_id) query = query.eq('id', args.source_id)
|
|
1263
|
-
|
|
1264
|
-
const { data: sources, error } = await query
|
|
1265
|
-
if (error) return textResult(`Error: ${error.message}`)
|
|
1266
|
-
if (!sources || sources.length === 0) {
|
|
1267
|
-
return textResult('No active watched sources. Use add_watched_source to add one.')
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const countdownExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
|
1271
|
-
const results: any[] = []
|
|
1272
|
-
|
|
1273
|
-
for (const source of sources) {
|
|
1274
|
-
let added = 0
|
|
1275
|
-
let skipped = 0
|
|
1276
|
-
try {
|
|
1277
|
-
const items = await getSourceItems(source)
|
|
1278
|
-
|
|
1279
|
-
for (const item of items) {
|
|
1280
|
-
const { data: dupe } = await supabase
|
|
1281
|
-
.from('bookmarks')
|
|
1282
|
-
.select('id')
|
|
1283
|
-
.eq('url', item.url)
|
|
1284
|
-
.maybeSingle()
|
|
1285
|
-
|
|
1286
|
-
if (dupe) { skipped++; continue }
|
|
1287
|
-
|
|
1288
|
-
const { error: insertErr } = await supabase
|
|
1289
|
-
.from('bookmarks')
|
|
1290
|
-
.insert({
|
|
1291
|
-
user_id: user.id,
|
|
1292
|
-
url: item.url,
|
|
1293
|
-
title: item.title,
|
|
1294
|
-
platform: detectPlatform(item.url),
|
|
1295
|
-
status: 'active',
|
|
1296
|
-
countdown_expires_at: countdownExpiresAt,
|
|
1297
|
-
content_metadata: {
|
|
1298
|
-
author: item.author || source.display_name,
|
|
1299
|
-
watched_source_id: source.id,
|
|
1300
|
-
watched_source_name: source.display_name,
|
|
1301
|
-
},
|
|
1302
|
-
})
|
|
1303
|
-
|
|
1304
|
-
if (!insertErr) added++
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
await supabase
|
|
1308
|
-
.from('watched_sources')
|
|
1309
|
-
.update({ last_checked_at: new Date().toISOString() })
|
|
1310
|
-
.eq('id', source.id)
|
|
1311
|
-
|
|
1312
|
-
results.push({ source: source.display_name, type: source.source_type, added, skipped })
|
|
1313
|
-
} catch (err: any) {
|
|
1314
|
-
results.push({ source: source.display_name, type: source.source_type, error: err.message })
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
const totalAdded = results.reduce((s, r) => s + (r.added || 0), 0)
|
|
1319
|
-
return textResult(JSON.stringify({ totalAdded, sources: results }, null, 2))
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
|
|
1323
|
-
server.tool(
|
|
1324
|
-
'search_vault',
|
|
1325
|
-
'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)',
|
|
1326
|
-
{ query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
|
|
1327
|
-
rateLimited(handleSearchVault)
|
|
1328
|
-
)
|
|
1329
|
-
|
|
1330
|
-
server.tool(
|
|
1331
|
-
'list_vault',
|
|
1332
|
-
'List bookmarks in your Vault, optionally filtered by category',
|
|
1333
|
-
{ limit: z.number().optional().describe('Max results (default 20)'), category: z.string().optional().describe('Filter by vault category') },
|
|
1334
|
-
rateLimited(handleListVault)
|
|
1335
|
-
)
|
|
1336
|
-
|
|
1337
|
-
server.tool(
|
|
1338
|
-
'list_sparks',
|
|
1339
|
-
'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.',
|
|
1340
|
-
{ limit: z.number().optional().describe('Max results (default 20)') },
|
|
1341
|
-
rateLimited(handleListSparks)
|
|
1342
|
-
)
|
|
1343
|
-
|
|
1344
|
-
server.tool(
|
|
1345
|
-
'search_sparks',
|
|
1346
|
-
'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)',
|
|
1347
|
-
{ query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
|
|
1348
|
-
rateLimited(handleSearchSparks)
|
|
1349
|
-
)
|
|
1350
|
-
|
|
1351
|
-
server.tool(
|
|
1352
|
-
'get_bookmark',
|
|
1353
|
-
'Get full details of a single bookmark including AI analysis and extracted content',
|
|
1354
|
-
{ id: z.string().describe('Bookmark UUID') },
|
|
1355
|
-
rateLimited(handleGetBookmark)
|
|
1356
|
-
)
|
|
1357
|
-
|
|
1358
|
-
server.tool(
|
|
1359
|
-
'get_article_content',
|
|
1360
|
-
'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)',
|
|
1361
|
-
{ id: z.string().describe('Bookmark UUID') },
|
|
1362
|
-
rateLimited(handleGetArticleContent)
|
|
1363
|
-
)
|
|
1364
|
-
|
|
1365
|
-
server.tool(
|
|
1366
|
-
'fetch_content',
|
|
1367
|
-
'Fetch article/tweet content from a URL. Works with X.com (bypasses GFW via proxy), Reddit, YouTube, Bilibili, WeChat, and any web page. First checks Supabase cache, then fetches live.',
|
|
1368
|
-
{ url: z.string().describe('The URL to fetch content from') },
|
|
1369
|
-
rateLimited(handleFetchContent)
|
|
1370
|
-
)
|
|
1371
|
-
|
|
1372
|
-
server.tool(
|
|
1373
|
-
'list_categories',
|
|
1374
|
-
'List all Vault categories with article counts',
|
|
1375
|
-
{},
|
|
1376
|
-
rateLimited(handleListCategories)
|
|
1377
|
-
)
|
|
1378
|
-
|
|
1379
|
-
server.tool(
|
|
1380
|
-
'list_flame',
|
|
1381
|
-
'List bookmarks in your Flame inbox (24h countdown). Shows AI triage info (strategy, relevance, novelty, hook) and time remaining. Use this to see what needs attention before it burns to Ash.',
|
|
1382
|
-
{ limit: z.number().optional().describe('Max results (default 20)') },
|
|
1383
|
-
rateLimited(handleListFlame)
|
|
1384
|
-
)
|
|
1385
|
-
|
|
1386
|
-
server.tool(
|
|
1387
|
-
'get_flame_detail',
|
|
1388
|
-
'Get full details of a Flame bookmark including extracted article content, AI analysis, and reading guidance. Use this to deep-read a bookmark before deciding its fate.',
|
|
1389
|
-
{ id: z.string().describe('Bookmark UUID') },
|
|
1390
|
-
rateLimited(handleGetFlameDetail)
|
|
1391
|
-
)
|
|
1392
|
-
|
|
1393
|
-
server.tool(
|
|
1394
|
-
'get_collections',
|
|
1395
|
-
'List all your Collections with article counts and AI overview themes',
|
|
1396
|
-
{},
|
|
1397
|
-
rateLimited(handleGetCollections)
|
|
1398
|
-
)
|
|
1399
|
-
|
|
1400
|
-
server.tool(
|
|
1401
|
-
'get_collection_overview',
|
|
1402
|
-
'Get a Collection by name with its AI overview and linked bookmarks metadata',
|
|
1403
|
-
{ name: z.string().describe('Collection name') },
|
|
1404
|
-
rateLimited(handleGetCollectionOverview)
|
|
1405
|
-
)
|
|
1406
|
-
|
|
1407
|
-
// ---------------------------------------------------------------------------
|
|
1408
|
-
// Layer 1: Status flow tools (决策层)
|
|
1409
|
-
// ---------------------------------------------------------------------------
|
|
1410
|
-
|
|
1411
|
-
server.tool(
|
|
1412
|
-
'move_flame_to_spark',
|
|
1413
|
-
'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.',
|
|
1414
|
-
{
|
|
1415
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1416
|
-
spark_insight: z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
|
|
1417
|
-
},
|
|
1418
|
-
rateLimited(handleMoveFlameToSpark)
|
|
1419
|
-
)
|
|
1420
|
-
|
|
1421
|
-
server.tool(
|
|
1422
|
-
'move_flame_to_ash',
|
|
1423
|
-
'Burn a Flame bookmark to Ash (not worth keeping).',
|
|
1424
|
-
{
|
|
1425
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1426
|
-
reason: z.string().max(200).optional().describe('Why this was burned'),
|
|
1427
|
-
},
|
|
1428
|
-
rateLimited(handleMoveFlameToAsh)
|
|
1429
|
-
)
|
|
1430
|
-
|
|
1431
|
-
server.tool(
|
|
1432
|
-
'move_spark_to_vault',
|
|
1433
|
-
'Promote a Spark bookmark to permanent Vault storage.',
|
|
1434
|
-
{
|
|
1435
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1436
|
-
vault_category: z.string().max(100).optional().describe('Category to file under in the Vault'),
|
|
1437
|
-
},
|
|
1438
|
-
rateLimited(handleMoveSparkToVault)
|
|
1439
|
-
)
|
|
1440
|
-
|
|
1441
|
-
server.tool(
|
|
1442
|
-
'move_spark_to_ash',
|
|
1443
|
-
'Burn a Spark bookmark to Ash (not valuable enough to vault).',
|
|
1444
|
-
{
|
|
1445
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1446
|
-
},
|
|
1447
|
-
rateLimited(handleMoveSparkToAsh)
|
|
1448
|
-
)
|
|
1449
|
-
|
|
1450
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1451
|
-
server.tool(
|
|
1452
|
-
'batch_triage_flame',
|
|
1453
|
-
'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.',
|
|
1454
|
-
{
|
|
1455
|
-
decisions: z.array(z.object({
|
|
1456
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1457
|
-
action: z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
|
|
1458
|
-
spark_insight: z.string().max(500).optional().describe('Insight (only for spark action)'),
|
|
1459
|
-
})).min(1).max(20).describe('Array of triage decisions'),
|
|
1460
|
-
},
|
|
1461
|
-
rateLimited(handleBatchTriageFlame)
|
|
1462
|
-
)
|
|
1463
|
-
|
|
1464
|
-
// ---------------------------------------------------------------------------
|
|
1465
|
-
// Layer 3: AI analysis writeback tools (分析层)
|
|
1466
|
-
// ---------------------------------------------------------------------------
|
|
1467
|
-
|
|
1468
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1469
|
-
server.tool(
|
|
1470
|
-
'write_bookmark_analysis',
|
|
1471
|
-
'Write AI analysis results into a bookmark. Agent analyzes content with its own LLM, then writes structured results back to Burn. Only provided fields are merged — existing data is preserved.',
|
|
1472
|
-
{
|
|
1473
|
-
id: z.string().describe('Bookmark UUID'),
|
|
1474
|
-
analysis: z.object({
|
|
1475
|
-
ai_summary: z.string().max(200).optional().describe('One-line summary'),
|
|
1476
|
-
ai_strategy: z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
|
|
1477
|
-
ai_strategy_reason: z.string().max(200).optional().describe('Why this strategy'),
|
|
1478
|
-
ai_minutes: z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
|
|
1479
|
-
ai_takeaway: z.array(z.string().max(200)).max(5).optional().describe('Key takeaways'),
|
|
1480
|
-
ai_relevance: z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
|
|
1481
|
-
ai_novelty: z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
|
|
1482
|
-
tags: z.array(z.string().max(50)).max(10).optional().describe('Topic tags'),
|
|
1483
|
-
}).describe('Analysis fields to write'),
|
|
1484
|
-
},
|
|
1485
|
-
rateLimited(handleWriteBookmarkAnalysis)
|
|
1486
|
-
)
|
|
1487
|
-
|
|
1488
|
-
// ---------------------------------------------------------------------------
|
|
1489
|
-
// Layer 2: Collection tools (组合层)
|
|
1490
|
-
// ---------------------------------------------------------------------------
|
|
1491
|
-
|
|
1492
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1493
|
-
server.tool(
|
|
1494
|
-
'create_collection',
|
|
1495
|
-
'Create a new Collection to group related bookmarks together.',
|
|
1496
|
-
{
|
|
1497
|
-
name: z.string().min(1).max(200).describe('Collection name'),
|
|
1498
|
-
bookmark_ids: z.array(z.string()).optional().describe('Initial bookmark UUIDs to include'),
|
|
1499
|
-
},
|
|
1500
|
-
rateLimited(handleCreateCollection)
|
|
1501
|
-
)
|
|
1502
|
-
|
|
1503
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1504
|
-
server.tool(
|
|
1505
|
-
'add_to_collection',
|
|
1506
|
-
'Add bookmarks to an existing Collection. Duplicates are silently ignored.',
|
|
1507
|
-
{
|
|
1508
|
-
collection_id: z.string().describe('Collection UUID'),
|
|
1509
|
-
bookmark_ids: z.array(z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
|
|
1510
|
-
},
|
|
1511
|
-
rateLimited(handleAddToCollection)
|
|
1512
|
-
)
|
|
1513
|
-
|
|
1514
|
-
server.tool(
|
|
1515
|
-
'remove_from_collection',
|
|
1516
|
-
'Remove bookmarks from a Collection.',
|
|
1517
|
-
{
|
|
1518
|
-
collection_id: z.string().describe('Collection UUID'),
|
|
1519
|
-
bookmark_ids: z.array(z.string()).min(1).describe('Bookmark UUIDs to remove'),
|
|
1520
|
-
},
|
|
1521
|
-
rateLimited(handleRemoveFromCollection)
|
|
1522
|
-
)
|
|
1523
|
-
|
|
1524
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1525
|
-
server.tool(
|
|
1526
|
-
'update_collection_overview',
|
|
1527
|
-
'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).',
|
|
1528
|
-
{
|
|
1529
|
-
collection_id: z.string().describe('Collection UUID'),
|
|
1530
|
-
overview: z.object({
|
|
1531
|
-
theme: z.string().describe('Overarching theme'),
|
|
1532
|
-
synthesis: z.string().optional().describe('Cross-bookmark synthesis'),
|
|
1533
|
-
patterns: z.array(z.string()).optional().describe('Patterns identified'),
|
|
1534
|
-
gaps: z.array(z.string()).optional().describe('Knowledge gaps identified'),
|
|
1535
|
-
}).describe('AI-generated overview'),
|
|
1536
|
-
},
|
|
1537
|
-
rateLimited(handleUpdateCollectionOverview)
|
|
1538
|
-
)
|
|
1539
|
-
|
|
1540
|
-
// ---------------------------------------------------------------------------
|
|
1541
|
-
// Watched Sources tools
|
|
1542
|
-
// ---------------------------------------------------------------------------
|
|
1543
|
-
|
|
1544
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1545
|
-
server.tool(
|
|
1546
|
-
'add_watched_source',
|
|
1547
|
-
'Watch an X user, RSS feed, or YouTube channel — new posts auto-appear in Burn Flame on each scrape.',
|
|
1548
|
-
{
|
|
1549
|
-
source_type: z.enum(['x_user', 'rss', 'youtube']).describe('x_user = Twitter/X handle | rss = any RSS/Atom feed URL | youtube = YouTube channel ID'),
|
|
1550
|
-
handle: z.string().describe('x_user: username without @ (e.g. "karpathy") | rss: full feed URL | youtube: channel ID starting with UC'),
|
|
1551
|
-
name: z.string().optional().describe('Human-friendly display name (defaults to handle)'),
|
|
1552
|
-
},
|
|
1553
|
-
rateLimited(handleAddWatchedSource)
|
|
1554
|
-
)
|
|
1555
|
-
|
|
1556
|
-
server.tool(
|
|
1557
|
-
'list_watched_sources',
|
|
1558
|
-
'List all active watched sources (X users, RSS feeds, YouTube channels).',
|
|
1559
|
-
{},
|
|
1560
|
-
rateLimited(handleListWatchedSources)
|
|
1561
|
-
)
|
|
1562
|
-
|
|
1563
|
-
server.tool(
|
|
1564
|
-
'remove_watched_source',
|
|
1565
|
-
'Stop watching a source. Use list_watched_sources to find the source ID.',
|
|
1566
|
-
{ id: z.string().describe('Watched source UUID from list_watched_sources') },
|
|
1567
|
-
rateLimited(handleRemoveWatchedSource)
|
|
1568
|
-
)
|
|
1569
|
-
|
|
1570
|
-
server.tool(
|
|
1571
|
-
'scrape_watched_sources',
|
|
1572
|
-
'Fetch new content from all watched sources (or one specific source) and add new items to Burn Flame. Call this on a schedule or on demand.',
|
|
1573
|
-
{ source_id: z.string().optional().describe('Scrape only this source ID — omit to scrape all active sources') },
|
|
1574
|
-
rateLimited(handleScrapeWatchedSources)
|
|
1575
|
-
)
|
|
1576
|
-
|
|
1577
|
-
// ---------------------------------------------------------------------------
|
|
1578
|
-
// Resource: burn://vault/bookmarks
|
|
1579
|
-
// ---------------------------------------------------------------------------
|
|
1580
|
-
|
|
1581
|
-
server.resource(
|
|
1582
|
-
'vault-bookmarks',
|
|
1583
|
-
'burn://vault/bookmarks',
|
|
1584
|
-
async (uri) => {
|
|
1585
|
-
const { data, error } = await supabase
|
|
1586
|
-
.from('bookmarks')
|
|
1587
|
-
.select('*')
|
|
1588
|
-
.eq('status', 'absorbed')
|
|
1589
|
-
.order('created_at', { ascending: false })
|
|
1590
|
-
|
|
1591
|
-
if (error) {
|
|
1592
|
-
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
return {
|
|
1596
|
-
contents: [{
|
|
1597
|
-
uri: uri.href,
|
|
1598
|
-
mimeType: 'application/json',
|
|
1599
|
-
text: JSON.stringify((data || []).map(metaSummary), null, 2),
|
|
1600
|
-
}],
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
)
|
|
1604
|
-
|
|
1605
|
-
// ---------------------------------------------------------------------------
|
|
1606
|
-
// Resource: burn://vault/categories
|
|
1607
|
-
// ---------------------------------------------------------------------------
|
|
1608
|
-
|
|
1609
|
-
server.resource(
|
|
1610
|
-
'vault-categories',
|
|
1611
|
-
'burn://vault/categories',
|
|
1612
|
-
async (uri) => {
|
|
1613
|
-
const { data, error } = await supabase
|
|
1614
|
-
.from('bookmarks')
|
|
1615
|
-
.select('content_metadata')
|
|
1616
|
-
.eq('status', 'absorbed')
|
|
1617
|
-
|
|
1618
|
-
if (error) {
|
|
1619
|
-
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
const counts: Record<string, number> = {}
|
|
1623
|
-
for (const row of data || []) {
|
|
1624
|
-
const cat = (row.content_metadata as any)?.vault_category || 'Uncategorized'
|
|
1625
|
-
counts[cat] = (counts[cat] || 0) + 1
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
return {
|
|
1629
|
-
contents: [{
|
|
1630
|
-
uri: uri.href,
|
|
1631
|
-
mimeType: 'application/json',
|
|
1632
|
-
text: JSON.stringify(
|
|
1633
|
-
Object.entries(counts).map(([category, count]) => ({ category, count })),
|
|
1634
|
-
null, 2
|
|
1635
|
-
),
|
|
1636
|
-
}],
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
)
|
|
1640
|
-
|
|
1641
|
-
// ---------------------------------------------------------------------------
|
|
1642
|
-
// Start
|
|
1643
|
-
// ---------------------------------------------------------------------------
|
|
1644
|
-
|
|
1645
|
-
async function main() {
|
|
1646
|
-
await initAuth()
|
|
50
|
+
const server = createBurnServer(supabase)
|
|
1647
51
|
const transport = new StdioServerTransport()
|
|
1648
52
|
await server.connect(transport)
|
|
1649
53
|
console.error('Burn MCP Server running on stdio')
|
|
1650
54
|
}
|
|
1651
55
|
|
|
1652
56
|
main().catch((err) => {
|
|
1653
|
-
console.error('Fatal error:', err)
|
|
57
|
+
console.error('Fatal error:', err.message || err)
|
|
1654
58
|
process.exit(1)
|
|
1655
59
|
})
|