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/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, SupabaseClient } from '@supabase/supabase-js'
6
- import { z } from 'zod'
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,1353 +20,40 @@ if (!MCP_TOKEN && !LEGACY_JWT) {
23
20
  process.exit(1)
24
21
  }
25
22
 
26
- // ---------------------------------------------------------------------------
27
- // Supabase client bootstrapped with anon key, session set after auth below
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 || 'burn451-2026-secret-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
- '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
- return textResult(JSON.stringify(results, null, 2))
649
- }
650
-
651
- async function handleSearchSparks(args: { query: string; limit?: number }) {
652
- const { query, limit } = args
653
- const { data, error } = await supabase
654
- .from('bookmarks')
655
- .select('*')
656
- .eq('status', 'read')
657
- .order('created_at', { ascending: false })
658
- .limit(50)
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 res = await handleMoveFlameToAsh({ id: decision.id })
867
- const parsed = JSON.parse(res.content[0].text)
868
- results.push({ id: decision.id, action: 'flame → ash', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title })
869
- }
870
- }
871
-
872
- const succeeded = results.filter(r => r.success).length
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
- // @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
1080
- server.tool(
1081
- 'search_vault',
1082
- 'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)',
1083
- { query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
1084
- rateLimited(handleSearchVault)
1085
- )
1086
-
1087
- server.tool(
1088
- 'list_vault',
1089
- 'List bookmarks in your Vault, optionally filtered by category',
1090
- { limit: z.number().optional().describe('Max results (default 20)'), category: z.string().optional().describe('Filter by vault category') },
1091
- rateLimited(handleListVault)
1092
- )
1093
-
1094
- server.tool(
1095
- 'list_sparks',
1096
- 'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.',
1097
- { limit: z.number().optional().describe('Max results (default 20)') },
1098
- rateLimited(handleListSparks)
1099
- )
1100
-
1101
- server.tool(
1102
- 'search_sparks',
1103
- 'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)',
1104
- { query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
1105
- rateLimited(handleSearchSparks)
1106
- )
1107
-
1108
- server.tool(
1109
- 'get_bookmark',
1110
- 'Get full details of a single bookmark including AI analysis and extracted content',
1111
- { id: z.string().describe('Bookmark UUID') },
1112
- rateLimited(handleGetBookmark)
1113
- )
1114
-
1115
- server.tool(
1116
- 'get_article_content',
1117
- 'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)',
1118
- { id: z.string().describe('Bookmark UUID') },
1119
- rateLimited(handleGetArticleContent)
1120
- )
1121
-
1122
- server.tool(
1123
- 'fetch_content',
1124
- '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.',
1125
- { url: z.string().describe('The URL to fetch content from') },
1126
- rateLimited(handleFetchContent)
1127
- )
1128
-
1129
- server.tool(
1130
- 'list_categories',
1131
- 'List all Vault categories with article counts',
1132
- {},
1133
- rateLimited(handleListCategories)
1134
- )
1135
-
1136
- server.tool(
1137
- 'list_flame',
1138
- '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.',
1139
- { limit: z.number().optional().describe('Max results (default 20)') },
1140
- rateLimited(handleListFlame)
1141
- )
1142
-
1143
- server.tool(
1144
- 'get_flame_detail',
1145
- '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.',
1146
- { id: z.string().describe('Bookmark UUID') },
1147
- rateLimited(handleGetFlameDetail)
1148
- )
1149
-
1150
- server.tool(
1151
- 'get_collections',
1152
- 'List all your Collections with article counts and AI overview themes',
1153
- {},
1154
- rateLimited(handleGetCollections)
1155
- )
1156
-
1157
- server.tool(
1158
- 'get_collection_overview',
1159
- 'Get a Collection by name with its AI overview and linked bookmarks metadata',
1160
- { name: z.string().describe('Collection name') },
1161
- rateLimited(handleGetCollectionOverview)
1162
- )
1163
-
1164
- // ---------------------------------------------------------------------------
1165
- // Layer 1: Status flow tools (决策层)
1166
- // ---------------------------------------------------------------------------
1167
-
1168
- server.tool(
1169
- 'move_flame_to_spark',
1170
- 'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.',
1171
- {
1172
- id: z.string().describe('Bookmark UUID'),
1173
- spark_insight: z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
1174
- },
1175
- rateLimited(handleMoveFlameToSpark)
1176
- )
1177
-
1178
- server.tool(
1179
- 'move_flame_to_ash',
1180
- 'Burn a Flame bookmark to Ash (not worth keeping).',
1181
- {
1182
- id: z.string().describe('Bookmark UUID'),
1183
- reason: z.string().max(200).optional().describe('Why this was burned'),
1184
- },
1185
- rateLimited(handleMoveFlameToAsh)
1186
- )
1187
-
1188
- server.tool(
1189
- 'move_spark_to_vault',
1190
- 'Promote a Spark bookmark to permanent Vault storage.',
1191
- {
1192
- id: z.string().describe('Bookmark UUID'),
1193
- vault_category: z.string().max(100).optional().describe('Category to file under in the Vault'),
1194
- },
1195
- rateLimited(handleMoveSparkToVault)
1196
- )
1197
-
1198
- server.tool(
1199
- 'move_spark_to_ash',
1200
- 'Burn a Spark bookmark to Ash (not valuable enough to vault).',
1201
- {
1202
- id: z.string().describe('Bookmark UUID'),
1203
- },
1204
- rateLimited(handleMoveSparkToAsh)
1205
- )
1206
-
1207
- // @ts-expect-error — MCP SDK TS2589
1208
- server.tool(
1209
- 'batch_triage_flame',
1210
- 'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.',
1211
- {
1212
- decisions: z.array(z.object({
1213
- id: z.string().describe('Bookmark UUID'),
1214
- action: z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
1215
- spark_insight: z.string().max(500).optional().describe('Insight (only for spark action)'),
1216
- })).min(1).max(20).describe('Array of triage decisions'),
1217
- },
1218
- rateLimited(handleBatchTriageFlame)
1219
- )
1220
-
1221
- // ---------------------------------------------------------------------------
1222
- // Layer 3: AI analysis writeback tools (分析层)
1223
- // ---------------------------------------------------------------------------
1224
-
1225
- // @ts-expect-error — MCP SDK TS2589
1226
- server.tool(
1227
- 'write_bookmark_analysis',
1228
- '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.',
1229
- {
1230
- id: z.string().describe('Bookmark UUID'),
1231
- analysis: z.object({
1232
- ai_summary: z.string().max(200).optional().describe('One-line summary'),
1233
- ai_strategy: z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
1234
- ai_strategy_reason: z.string().max(200).optional().describe('Why this strategy'),
1235
- ai_minutes: z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
1236
- ai_takeaway: z.array(z.string().max(200)).max(5).optional().describe('Key takeaways'),
1237
- ai_relevance: z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
1238
- ai_novelty: z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
1239
- tags: z.array(z.string().max(50)).max(10).optional().describe('Topic tags'),
1240
- }).describe('Analysis fields to write'),
1241
- },
1242
- rateLimited(handleWriteBookmarkAnalysis)
1243
- )
1244
-
1245
- // ---------------------------------------------------------------------------
1246
- // Layer 2: Collection tools (组合层)
1247
- // ---------------------------------------------------------------------------
1248
-
1249
- // @ts-expect-error — MCP SDK TS2589
1250
- server.tool(
1251
- 'create_collection',
1252
- 'Create a new Collection to group related bookmarks together.',
1253
- {
1254
- name: z.string().min(1).max(200).describe('Collection name'),
1255
- bookmark_ids: z.array(z.string()).optional().describe('Initial bookmark UUIDs to include'),
1256
- },
1257
- rateLimited(handleCreateCollection)
1258
- )
1259
-
1260
- // @ts-expect-error — MCP SDK TS2589
1261
- server.tool(
1262
- 'add_to_collection',
1263
- 'Add bookmarks to an existing Collection. Duplicates are silently ignored.',
1264
- {
1265
- collection_id: z.string().describe('Collection UUID'),
1266
- bookmark_ids: z.array(z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
1267
- },
1268
- rateLimited(handleAddToCollection)
1269
- )
1270
-
1271
- server.tool(
1272
- 'remove_from_collection',
1273
- 'Remove bookmarks from a Collection.',
1274
- {
1275
- collection_id: z.string().describe('Collection UUID'),
1276
- bookmark_ids: z.array(z.string()).min(1).describe('Bookmark UUIDs to remove'),
1277
- },
1278
- rateLimited(handleRemoveFromCollection)
1279
- )
1280
-
1281
- // @ts-expect-error — MCP SDK TS2589
1282
- server.tool(
1283
- 'update_collection_overview',
1284
- 'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).',
1285
- {
1286
- collection_id: z.string().describe('Collection UUID'),
1287
- overview: z.object({
1288
- theme: z.string().describe('Overarching theme'),
1289
- synthesis: z.string().optional().describe('Cross-bookmark synthesis'),
1290
- patterns: z.array(z.string()).optional().describe('Patterns identified'),
1291
- gaps: z.array(z.string()).optional().describe('Knowledge gaps identified'),
1292
- }).describe('AI-generated overview'),
1293
- },
1294
- rateLimited(handleUpdateCollectionOverview)
1295
- )
1296
-
1297
- // ---------------------------------------------------------------------------
1298
- // Resource: burn://vault/bookmarks
1299
- // ---------------------------------------------------------------------------
1300
-
1301
- server.resource(
1302
- 'vault-bookmarks',
1303
- 'burn://vault/bookmarks',
1304
- async (uri) => {
1305
- const { data, error } = await supabase
1306
- .from('bookmarks')
1307
- .select('*')
1308
- .eq('status', 'absorbed')
1309
- .order('created_at', { ascending: false })
1310
-
1311
- if (error) {
1312
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
1313
- }
1314
-
1315
- return {
1316
- contents: [{
1317
- uri: uri.href,
1318
- mimeType: 'application/json',
1319
- text: JSON.stringify((data || []).map(metaSummary), null, 2),
1320
- }],
1321
- }
1322
- }
1323
- )
1324
-
1325
- // ---------------------------------------------------------------------------
1326
- // Resource: burn://vault/categories
1327
- // ---------------------------------------------------------------------------
1328
-
1329
- server.resource(
1330
- 'vault-categories',
1331
- 'burn://vault/categories',
1332
- async (uri) => {
1333
- const { data, error } = await supabase
1334
- .from('bookmarks')
1335
- .select('content_metadata')
1336
- .eq('status', 'absorbed')
1337
-
1338
- if (error) {
1339
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
1340
- }
1341
-
1342
- const counts: Record<string, number> = {}
1343
- for (const row of data || []) {
1344
- const cat = (row.content_metadata as any)?.vault_category || 'Uncategorized'
1345
- counts[cat] = (counts[cat] || 0) + 1
1346
- }
1347
-
1348
- return {
1349
- contents: [{
1350
- uri: uri.href,
1351
- mimeType: 'application/json',
1352
- text: JSON.stringify(
1353
- Object.entries(counts).map(([category, count]) => ({ category, count })),
1354
- null, 2
1355
- ),
1356
- }],
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')
1357
47
  }
1358
48
  }
1359
- )
1360
49
 
1361
- // ---------------------------------------------------------------------------
1362
- // Start
1363
- // ---------------------------------------------------------------------------
1364
-
1365
- async function main() {
1366
- await initAuth()
50
+ const server = createBurnServer(supabase)
1367
51
  const transport = new StdioServerTransport()
1368
52
  await server.connect(transport)
1369
53
  console.error('Burn MCP Server running on stdio')
1370
54
  }
1371
55
 
1372
56
  main().catch((err) => {
1373
- console.error('Fatal error:', err)
57
+ console.error('Fatal error:', err.message || err)
1374
58
  process.exit(1)
1375
59
  })