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