burn-mcp-server 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +394 -112
  2. package/package.json +1 -1
  3. package/src/index.ts +490 -144
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ const zod_1 = require("zod");
9
9
  // Config
10
10
  // ---------------------------------------------------------------------------
11
11
  const SUPABASE_URL = process.env.BURN_SUPABASE_URL || 'https://juqtxylquemiuvvmgbej.supabase.co';
12
+ const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO';
12
13
  const SUPABASE_TOKEN = process.env.BURN_SUPABASE_TOKEN;
13
14
  if (!SUPABASE_TOKEN) {
14
15
  console.error('Error: BURN_SUPABASE_TOKEN environment variable is required.');
@@ -18,7 +19,7 @@ if (!SUPABASE_TOKEN) {
18
19
  // ---------------------------------------------------------------------------
19
20
  // Supabase client (authenticated as user via JWT)
20
21
  // ---------------------------------------------------------------------------
21
- const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_TOKEN, {
22
+ const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY, {
22
23
  auth: {
23
24
  persistSession: false,
24
25
  autoRefreshToken: false,
@@ -34,149 +35,430 @@ const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_TOKEN, {
34
35
  // ---------------------------------------------------------------------------
35
36
  const server = new mcp_js_1.McpServer({
36
37
  name: 'burn-mcp-server',
37
- version: '1.0.0',
38
+ version: '1.1.0',
38
39
  });
39
40
  // ---------------------------------------------------------------------------
40
- // Tool: search_vault
41
+ // Helper: standard text result
41
42
  // ---------------------------------------------------------------------------
42
- server.tool('search_vault', 'Search your Burn Vault for bookmarks by keyword', {
43
- query: zod_1.z.string().describe('Search keyword'),
44
- limit: zod_1.z.number().optional().default(10).describe('Max results (default 10)'),
45
- }, async ({ query, limit }) => {
46
- const { data, error } = await supabase
47
- .from('bookmarks')
48
- .select('id, url, title, author, ai_positioning, ai_takeaway, tags, vault_category, vaulted_at')
49
- .eq('status', 'vault')
50
- .or(`title.ilike.%${query}%,ai_takeaway.cs.{${query}},tags.cs.{${query}}`)
51
- .order('vaulted_at', { ascending: false })
52
- .limit(limit || 10);
53
- if (error) {
54
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
55
- }
43
+ function textResult(text) {
44
+ return { content: [{ type: 'text', text }] };
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Helper: extract fields from content_metadata JSONB
48
+ // ---------------------------------------------------------------------------
49
+ function meta(row) {
50
+ const m = row.content_metadata || {};
56
51
  return {
57
- content: [{
58
- type: 'text',
59
- text: JSON.stringify(data, null, 2),
60
- }],
52
+ id: row.id,
53
+ url: row.url,
54
+ title: row.title,
55
+ author: m.author || row.author || null,
56
+ platform: row.platform,
57
+ status: row.status,
58
+ tags: m.tags || [],
59
+ thumbnail: m.thumbnail || null,
60
+ vaultCategory: m.vault_category || null,
61
+ vaultedAt: m.vaulted_at || null,
62
+ aiPositioning: m.ai_positioning || null,
63
+ aiDensity: m.ai_density || null,
64
+ aiMinutes: m.ai_minutes || null,
65
+ aiTakeaway: m.ai_takeaway || [],
66
+ aiStrategyReason: m.ai_strategy_reason || null,
67
+ aiHowToRead: m.ai_how_to_read || null,
68
+ aiOverlap: m.ai_overlap || null,
69
+ aiVerdict: m.ai_verdict || null,
70
+ aiSummary: m.ai_summary || null,
71
+ sparkInsight: m.spark_insight || null,
72
+ extractedContent: m.extracted_content || null,
73
+ externalURL: m.external_url || null,
74
+ aiRelevance: m.ai_relevance || null,
75
+ aiNovelty: m.ai_novelty || null,
76
+ createdAt: row.created_at,
77
+ countdownExpiresAt: row.countdown_expires_at,
78
+ readAt: row.read_at,
61
79
  };
62
- });
80
+ }
81
+ /** Compact summary for list views (no extracted content) */
82
+ function metaSummary(row) {
83
+ const m = row.content_metadata || {};
84
+ return {
85
+ id: row.id,
86
+ url: row.url,
87
+ title: row.title,
88
+ author: m.author || null,
89
+ platform: row.platform,
90
+ tags: m.tags || [],
91
+ vaultCategory: m.vault_category || null,
92
+ vaultedAt: m.vaulted_at || null,
93
+ aiPositioning: m.ai_positioning || null,
94
+ aiTakeaway: m.ai_takeaway || [],
95
+ };
96
+ }
63
97
  // ---------------------------------------------------------------------------
64
- // Tool: get_bookmark
98
+ // Tool handlers
65
99
  // ---------------------------------------------------------------------------
66
- server.tool('get_bookmark', 'Get full details of a single bookmark including AI analysis and content', {
67
- id: zod_1.z.string().describe('Bookmark UUID'),
68
- }, async ({ id }) => {
100
+ async function handleSearchVault(args) {
101
+ const { query, limit } = args;
69
102
  const { data, error } = await supabase
70
103
  .from('bookmarks')
71
104
  .select('*')
72
- .eq('id', id)
73
- .single();
74
- if (error) {
75
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
105
+ .eq('status', 'absorbed')
106
+ .ilike('title', `%${query}%`)
107
+ .order('created_at', { ascending: false })
108
+ .limit(limit || 10);
109
+ if (error)
110
+ return textResult(`Error: ${error.message}`);
111
+ // Also search in content_metadata tags and takeaway
112
+ let results = (data || []).map(metaSummary);
113
+ // If title search returned few results, also search by tag
114
+ if (results.length < (limit || 10)) {
115
+ const { data: tagData } = await supabase
116
+ .from('bookmarks')
117
+ .select('*')
118
+ .eq('status', 'absorbed')
119
+ .order('created_at', { ascending: false })
120
+ .limit(50);
121
+ if (tagData) {
122
+ const existingIds = new Set(results.map((r) => r.id));
123
+ const tagMatches = tagData
124
+ .filter((row) => {
125
+ if (existingIds.has(row.id))
126
+ return false;
127
+ const m = row.content_metadata || {};
128
+ const tags = (m.tags || []);
129
+ const takeaway = (m.ai_takeaway || []);
130
+ const positioning = m.ai_positioning || '';
131
+ const allText = [...tags, ...takeaway, positioning].join(' ').toLowerCase();
132
+ return allText.includes(query.toLowerCase());
133
+ })
134
+ .map(metaSummary);
135
+ results = [...results, ...tagMatches].slice(0, limit || 10);
136
+ }
76
137
  }
77
- return {
78
- content: [{
79
- type: 'text',
80
- text: JSON.stringify(data, null, 2),
81
- }],
82
- };
83
- });
84
- // ---------------------------------------------------------------------------
85
- // Tool: list_categories
86
- // ---------------------------------------------------------------------------
87
- server.tool('list_categories', 'List all Vault categories with article counts', {}, async () => {
138
+ return textResult(JSON.stringify(results, null, 2));
139
+ }
140
+ async function handleGetBookmark(args) {
88
141
  const { data, error } = await supabase
89
142
  .from('bookmarks')
90
- .select('vault_category')
91
- .eq('status', 'vault');
92
- if (error) {
93
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
94
- }
95
- // Group by category and count
143
+ .select('*')
144
+ .eq('id', args.id)
145
+ .single();
146
+ if (error)
147
+ return textResult(`Error: ${error.message}`);
148
+ return textResult(JSON.stringify(meta(data), null, 2));
149
+ }
150
+ async function handleListCategories() {
151
+ const { data, error } = await supabase
152
+ .from('bookmarks')
153
+ .select('content_metadata')
154
+ .eq('status', 'absorbed');
155
+ if (error)
156
+ return textResult(`Error: ${error.message}`);
96
157
  const counts = {};
97
158
  for (const row of data || []) {
98
- const cat = row.vault_category || 'Uncategorized';
159
+ const cat = row.content_metadata?.vault_category || 'Uncategorized';
99
160
  counts[cat] = (counts[cat] || 0) + 1;
100
161
  }
101
162
  const categories = Object.entries(counts)
102
163
  .map(([category, count]) => ({ category, count }))
103
164
  .sort((a, b) => b.count - a.count);
104
- return {
105
- content: [{
106
- type: 'text',
107
- text: JSON.stringify(categories, null, 2),
108
- }],
109
- };
110
- });
111
- // ---------------------------------------------------------------------------
112
- // Tool: get_clusters
113
- // ---------------------------------------------------------------------------
114
- server.tool('get_clusters', 'Get AI-generated topic clusters from your Vault', {}, async () => {
165
+ return textResult(JSON.stringify(categories, null, 2));
166
+ }
167
+ async function handleGetCollections() {
115
168
  const { data, error } = await supabase
116
- .from('vault_clusters')
117
- .select('clusters, generated_at, stale')
169
+ .from('collections')
170
+ .select('id, name, bookmark_ids, ai_overview');
171
+ if (error)
172
+ return textResult(`Error: ${error.message}`);
173
+ const collections = (data || []).map((c) => ({
174
+ id: c.id,
175
+ name: c.name,
176
+ articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
177
+ overview: c.ai_overview?.theme || null,
178
+ }));
179
+ return textResult(JSON.stringify(collections, null, 2));
180
+ }
181
+ async function handleGetCollectionOverview(args) {
182
+ const { data: collection, error } = await supabase
183
+ .from('collections')
184
+ .select('*')
185
+ .eq('name', args.name)
118
186
  .single();
119
187
  if (error) {
120
- return {
121
- content: [{
122
- type: 'text',
123
- text: error.code === 'PGRST116'
124
- ? 'No clusters generated yet. Open the Vault tab in Burn to generate clusters.'
125
- : `Error: ${error.message}`,
126
- }],
127
- };
188
+ return textResult(error.code === 'PGRST116'
189
+ ? `No collection found with name "${args.name}".`
190
+ : `Error: ${error.message}`);
128
191
  }
129
- return {
130
- content: [{
131
- type: 'text',
132
- text: JSON.stringify({
133
- clusters: data.clusters,
134
- generatedAt: data.generated_at,
135
- isStale: data.stale,
136
- }, null, 2),
137
- }],
138
- };
139
- });
192
+ let bookmarks = [];
193
+ if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
194
+ const { data: bData, error: bError } = await supabase
195
+ .from('bookmarks')
196
+ .select('*')
197
+ .in('id', collection.bookmark_ids);
198
+ if (!bError && bData) {
199
+ bookmarks = bData.map(metaSummary);
200
+ }
201
+ }
202
+ return textResult(JSON.stringify({
203
+ id: collection.id,
204
+ name: collection.name,
205
+ articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
206
+ aiOverview: collection.ai_overview,
207
+ bookmarks,
208
+ }, null, 2));
209
+ }
210
+ async function handleGetArticleContent(args) {
211
+ const { data, error } = await supabase
212
+ .from('bookmarks')
213
+ .select('*')
214
+ .eq('id', args.id)
215
+ .single();
216
+ if (error)
217
+ return textResult(`Error: ${error.message}`);
218
+ return textResult(JSON.stringify(meta(data), null, 2));
219
+ }
140
220
  // ---------------------------------------------------------------------------
141
- // Tool: get_cluster_digest
221
+ // Vercel API base URL for content fetching
142
222
  // ---------------------------------------------------------------------------
143
- server.tool('get_cluster_digest', 'Get the AI-generated digest (summary, insights, relationships) for a topic cluster', {
144
- clusterName: zod_1.z.string().describe('Name of the cluster'),
145
- }, async ({ clusterName }) => {
223
+ const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud';
224
+ const API_KEY = 'burn451-2026-secret-key';
225
+ /** Detect platform from URL */
226
+ function detectPlatform(url) {
227
+ if (/x\.com|twitter\.com/i.test(url))
228
+ return 'x';
229
+ if (/youtube\.com|youtu\.be/i.test(url))
230
+ return 'youtube';
231
+ if (/reddit\.com|redd\.it/i.test(url))
232
+ return 'reddit';
233
+ if (/bilibili\.com|b23\.tv/i.test(url))
234
+ return 'bilibili';
235
+ if (/open\.spotify\.com/i.test(url))
236
+ return 'spotify';
237
+ if (/mp\.weixin\.qq\.com/i.test(url))
238
+ return 'wechat';
239
+ if (/xiaohongshu\.com|xhslink\.com/i.test(url))
240
+ return 'xhs';
241
+ return 'web';
242
+ }
243
+ /** Fetch content via Vercel API proxy (bypasses GFW for X.com, etc.) */
244
+ async function fetchViaAPI(url, platform) {
245
+ try {
246
+ let endpoint;
247
+ let params;
248
+ switch (platform) {
249
+ case 'x':
250
+ endpoint = `${API_BASE}/api/parse-x`;
251
+ params = `url=${encodeURIComponent(url)}`;
252
+ break;
253
+ case 'reddit':
254
+ endpoint = `${API_BASE}/api/parse-reddit`;
255
+ params = `url=${encodeURIComponent(url)}`;
256
+ break;
257
+ case 'spotify':
258
+ endpoint = `${API_BASE}/api/parse-meta`;
259
+ params = `url=${encodeURIComponent(url)}&_platform=spotify`;
260
+ break;
261
+ case 'wechat':
262
+ case 'xhs':
263
+ endpoint = `${API_BASE}/api/parse-meta`;
264
+ params = `url=${encodeURIComponent(url)}&_platform=${platform}`;
265
+ break;
266
+ case 'youtube':
267
+ // Try transcript extraction via jina-extract with youtube platform hint
268
+ endpoint = `${API_BASE}/api/jina-extract`;
269
+ params = `url=${encodeURIComponent(url)}&_platform=youtube`;
270
+ break;
271
+ default:
272
+ endpoint = `${API_BASE}/api/jina-extract`;
273
+ params = `url=${encodeURIComponent(url)}`;
274
+ break;
275
+ }
276
+ const resp = await fetch(`${endpoint}?${params}`, {
277
+ headers: {
278
+ 'x-api-key': API_KEY,
279
+ 'Accept': 'application/json',
280
+ },
281
+ signal: AbortSignal.timeout(30000),
282
+ });
283
+ if (!resp.ok) {
284
+ return { error: `API returned ${resp.status}` };
285
+ }
286
+ const data = await resp.json();
287
+ // Normalize response across different API endpoints
288
+ if (platform === 'x') {
289
+ const text = data.text || data.article_text || '';
290
+ const quoteText = data.quote ? `\n\n[Quote from @${data.quote.handle}]: ${data.quote.text}` : '';
291
+ return {
292
+ title: data.text?.slice(0, 100) || 'Tweet',
293
+ author: data.author ? `@${data.handle || data.author}` : undefined,
294
+ content: text + quoteText,
295
+ };
296
+ }
297
+ if (platform === 'spotify') {
298
+ return {
299
+ title: data.title,
300
+ author: data.author,
301
+ content: data.extracted_content || data.description || null,
302
+ };
303
+ }
304
+ if (platform === 'wechat') {
305
+ // WeChat parse-meta returns extracted_content from js_content div
306
+ return {
307
+ title: data.title,
308
+ author: data.author,
309
+ content: data.extracted_content || data.content || null,
310
+ };
311
+ }
312
+ return {
313
+ title: data.title,
314
+ author: data.author,
315
+ content: data.content || data.extracted_content || data.text || data.transcript || null,
316
+ };
317
+ }
318
+ catch (err) {
319
+ return { error: err.message || 'Fetch failed' };
320
+ }
321
+ }
322
+ async function handleFetchContent(args) {
323
+ const { url } = args;
324
+ const platform = detectPlatform(url);
325
+ // Step 1: Check if we already have content in Supabase
326
+ const { data: existing } = await supabase
327
+ .from('bookmarks')
328
+ .select('*')
329
+ .eq('url', url)
330
+ .limit(1)
331
+ .maybeSingle();
332
+ if (existing) {
333
+ const m = existing.content_metadata || {};
334
+ if (m.extracted_content && m.extracted_content.length > 50) {
335
+ return textResult(JSON.stringify({
336
+ source: 'cache',
337
+ url,
338
+ platform,
339
+ title: existing.title,
340
+ author: m.author,
341
+ content: m.extracted_content,
342
+ aiPositioning: m.ai_positioning,
343
+ aiTakeaway: m.ai_takeaway,
344
+ tags: m.tags,
345
+ }, null, 2));
346
+ }
347
+ }
348
+ // Step 2: Fetch fresh content via Vercel API
349
+ const result = await fetchViaAPI(url, platform);
350
+ if (result.error) {
351
+ return textResult(JSON.stringify({
352
+ source: 'error',
353
+ url,
354
+ platform,
355
+ error: result.error,
356
+ hint: platform === 'x' ? 'X.com content is fetched via Vercel Edge proxy to bypass GFW' : undefined,
357
+ }, null, 2));
358
+ }
359
+ return textResult(JSON.stringify({
360
+ source: 'live',
361
+ url,
362
+ platform,
363
+ title: result.title,
364
+ author: result.author,
365
+ content: result.content,
366
+ }, null, 2));
367
+ }
368
+ async function handleListSparks(args) {
146
369
  const { data, error } = await supabase
147
- .from('cluster_digests')
148
- .select('digest, generated_at')
149
- .eq('cluster_name', clusterName)
150
- .single();
151
- if (error) {
370
+ .from('bookmarks')
371
+ .select('*')
372
+ .eq('status', 'read')
373
+ .order('created_at', { ascending: false })
374
+ .limit(args.limit || 20);
375
+ if (error)
376
+ return textResult(`Error: ${error.message}`);
377
+ const results = (data || []).map((row) => {
378
+ const s = metaSummary(row);
379
+ const m = row.content_metadata || {};
152
380
  return {
153
- content: [{
154
- type: 'text',
155
- text: error.code === 'PGRST116'
156
- ? `No digest found for cluster "${clusterName}". Open the cluster in Burn App to generate a digest first.`
157
- : `Error: ${error.message}`,
158
- }],
381
+ ...s,
382
+ sparkInsight: m.spark_insight || null,
383
+ sparkExpiresAt: m.spark_expires_at || null,
159
384
  };
385
+ });
386
+ return textResult(JSON.stringify(results, null, 2));
387
+ }
388
+ async function handleSearchSparks(args) {
389
+ const { query, limit } = args;
390
+ const { data, error } = await supabase
391
+ .from('bookmarks')
392
+ .select('*')
393
+ .eq('status', 'read')
394
+ .order('created_at', { ascending: false })
395
+ .limit(50);
396
+ if (error)
397
+ return textResult(`Error: ${error.message}`);
398
+ const results = (data || [])
399
+ .filter((row) => {
400
+ const m = row.content_metadata || {};
401
+ const searchable = [
402
+ row.title || '',
403
+ ...(m.tags || []),
404
+ ...(m.ai_takeaway || []),
405
+ m.ai_positioning || '',
406
+ m.spark_insight || '',
407
+ ].join(' ').toLowerCase();
408
+ return searchable.includes(query.toLowerCase());
409
+ })
410
+ .slice(0, limit || 10)
411
+ .map((row) => {
412
+ const s = metaSummary(row);
413
+ const m = row.content_metadata || {};
414
+ return {
415
+ ...s,
416
+ sparkInsight: m.spark_insight || null,
417
+ sparkExpiresAt: m.spark_expires_at || null,
418
+ };
419
+ });
420
+ return textResult(JSON.stringify(results, null, 2));
421
+ }
422
+ async function handleListVault(args) {
423
+ let query = supabase
424
+ .from('bookmarks')
425
+ .select('*')
426
+ .eq('status', 'absorbed')
427
+ .order('created_at', { ascending: false })
428
+ .limit(args.limit || 20);
429
+ const { data, error } = await query;
430
+ if (error)
431
+ return textResult(`Error: ${error.message}`);
432
+ let results = (data || []).map(metaSummary);
433
+ // Filter by category if provided
434
+ if (args.category) {
435
+ results = results.filter((r) => r.vaultCategory?.toLowerCase() === args.category.toLowerCase());
160
436
  }
161
- return {
162
- content: [{
163
- type: 'text',
164
- text: JSON.stringify({
165
- ...data.digest,
166
- generatedAt: data.generated_at,
167
- }, null, 2),
168
- }],
169
- };
170
- });
437
+ return textResult(JSON.stringify(results, null, 2));
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // Register tools
441
+ // ---------------------------------------------------------------------------
442
+ // @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
443
+ server.tool('search_vault', 'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)', { query: zod_1.z.string().describe('Search keyword'), limit: zod_1.z.number().optional().describe('Max results (default 10)') }, handleSearchVault);
444
+ server.tool('list_vault', 'List bookmarks in your Vault, optionally filtered by category', { limit: zod_1.z.number().optional().describe('Max results (default 20)'), category: zod_1.z.string().optional().describe('Filter by vault category') }, handleListVault);
445
+ server.tool('list_sparks', 'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.', { limit: zod_1.z.number().optional().describe('Max results (default 20)') }, handleListSparks);
446
+ server.tool('search_sparks', 'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)', { query: zod_1.z.string().describe('Search keyword'), limit: zod_1.z.number().optional().describe('Max results (default 10)') }, handleSearchSparks);
447
+ server.tool('get_bookmark', 'Get full details of a single bookmark including AI analysis and extracted content', { id: zod_1.z.string().describe('Bookmark UUID') }, handleGetBookmark);
448
+ server.tool('get_article_content', 'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)', { id: zod_1.z.string().describe('Bookmark UUID') }, handleGetArticleContent);
449
+ server.tool('fetch_content', '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.', { url: zod_1.z.string().describe('The URL to fetch content from') }, handleFetchContent);
450
+ server.tool('list_categories', 'List all Vault categories with article counts', {}, handleListCategories);
451
+ server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, handleGetCollections);
452
+ server.tool('get_collection_overview', 'Get a Collection by name with its AI overview and linked bookmarks metadata', { name: zod_1.z.string().describe('Collection name') }, handleGetCollectionOverview);
171
453
  // ---------------------------------------------------------------------------
172
454
  // Resource: burn://vault/bookmarks
173
455
  // ---------------------------------------------------------------------------
174
456
  server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
175
457
  const { data, error } = await supabase
176
458
  .from('bookmarks')
177
- .select('id, url, title, author, ai_positioning, tags, vault_category, vaulted_at')
178
- .eq('status', 'vault')
179
- .order('vaulted_at', { ascending: false });
459
+ .select('*')
460
+ .eq('status', 'absorbed')
461
+ .order('created_at', { ascending: false });
180
462
  if (error) {
181
463
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
182
464
  }
@@ -184,7 +466,7 @@ server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
184
466
  contents: [{
185
467
  uri: uri.href,
186
468
  mimeType: 'application/json',
187
- text: JSON.stringify(data, null, 2),
469
+ text: JSON.stringify((data || []).map(metaSummary), null, 2),
188
470
  }],
189
471
  };
190
472
  });
@@ -194,14 +476,14 @@ server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
194
476
  server.resource('vault-categories', 'burn://vault/categories', async (uri) => {
195
477
  const { data, error } = await supabase
196
478
  .from('bookmarks')
197
- .select('vault_category')
198
- .eq('status', 'vault');
479
+ .select('content_metadata')
480
+ .eq('status', 'absorbed');
199
481
  if (error) {
200
482
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
201
483
  }
202
484
  const counts = {};
203
485
  for (const row of data || []) {
204
- const cat = row.vault_category || 'Uncategorized';
486
+ const cat = row.content_metadata?.vault_category || 'Uncategorized';
205
487
  counts[cat] = (counts[cat] || 0) + 1;
206
488
  }
207
489
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burn-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP Server for Burn — access your Vault from Claude/Cursor",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
5
  import { createClient, SupabaseClient } from '@supabase/supabase-js'
6
6
  import { z } from 'zod'
@@ -10,6 +10,7 @@ import { z } from 'zod'
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  const SUPABASE_URL = process.env.BURN_SUPABASE_URL || 'https://juqtxylquemiuvvmgbej.supabase.co'
13
+ const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO'
13
14
  const SUPABASE_TOKEN = process.env.BURN_SUPABASE_TOKEN
14
15
 
15
16
  if (!SUPABASE_TOKEN) {
@@ -22,7 +23,7 @@ if (!SUPABASE_TOKEN) {
22
23
  // Supabase client (authenticated as user via JWT)
23
24
  // ---------------------------------------------------------------------------
24
25
 
25
- const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_TOKEN, {
26
+ const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
26
27
  auth: {
27
28
  persistSession: false,
28
29
  autoRefreshToken: false,
@@ -40,186 +41,531 @@ const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_TOKEN, {
40
41
 
41
42
  const server = new McpServer({
42
43
  name: 'burn-mcp-server',
43
- version: '1.0.0',
44
+ version: '1.1.0',
44
45
  })
45
46
 
46
47
  // ---------------------------------------------------------------------------
47
- // Tool: search_vault
48
+ // Helper: standard text result
48
49
  // ---------------------------------------------------------------------------
49
50
 
50
- server.tool(
51
- 'search_vault',
52
- 'Search your Burn Vault for bookmarks by keyword',
53
- {
54
- query: z.string().describe('Search keyword'),
55
- limit: z.number().optional().default(10).describe('Max results (default 10)'),
56
- },
57
- async ({ query, limit }) => {
58
- const { data, error } = await supabase
59
- .from('bookmarks')
60
- .select('id, url, title, author, ai_positioning, ai_takeaway, tags, vault_category, vaulted_at')
61
- .eq('status', 'vault')
62
- .or(`title.ilike.%${query}%,ai_takeaway.cs.{${query}},tags.cs.{${query}}`)
63
- .order('vaulted_at', { ascending: false })
64
- .limit(limit || 10)
51
+ function textResult(text: string) {
52
+ return { content: [{ type: 'text' as const, text }] }
53
+ }
65
54
 
66
- if (error) {
67
- return { content: [{ type: 'text' as const, text: `Error: ${error.message}` }] }
68
- }
55
+ // ---------------------------------------------------------------------------
56
+ // Helper: extract fields from content_metadata JSONB
57
+ // ---------------------------------------------------------------------------
69
58
 
70
- return {
71
- content: [{
72
- type: 'text' as const,
73
- text: JSON.stringify(data, null, 2),
74
- }],
75
- }
59
+ function meta(row: any): any {
60
+ const m = row.content_metadata || {}
61
+ return {
62
+ id: row.id,
63
+ url: row.url,
64
+ title: row.title,
65
+ author: m.author || row.author || null,
66
+ platform: row.platform,
67
+ status: row.status,
68
+ tags: m.tags || [],
69
+ thumbnail: m.thumbnail || null,
70
+ vaultCategory: m.vault_category || null,
71
+ vaultedAt: m.vaulted_at || null,
72
+ aiPositioning: m.ai_positioning || null,
73
+ aiDensity: m.ai_density || null,
74
+ aiMinutes: m.ai_minutes || null,
75
+ aiTakeaway: m.ai_takeaway || [],
76
+ aiStrategyReason: m.ai_strategy_reason || null,
77
+ aiHowToRead: m.ai_how_to_read || null,
78
+ aiOverlap: m.ai_overlap || null,
79
+ aiVerdict: m.ai_verdict || null,
80
+ aiSummary: m.ai_summary || null,
81
+ sparkInsight: m.spark_insight || null,
82
+ extractedContent: m.extracted_content || null,
83
+ externalURL: m.external_url || null,
84
+ aiRelevance: m.ai_relevance || null,
85
+ aiNovelty: m.ai_novelty || null,
86
+ createdAt: row.created_at,
87
+ countdownExpiresAt: row.countdown_expires_at,
88
+ readAt: row.read_at,
76
89
  }
77
- )
90
+ }
91
+
92
+ /** Compact summary for list views (no extracted content) */
93
+ function metaSummary(row: any): any {
94
+ const m = row.content_metadata || {}
95
+ return {
96
+ id: row.id,
97
+ url: row.url,
98
+ title: row.title,
99
+ author: m.author || null,
100
+ platform: row.platform,
101
+ tags: m.tags || [],
102
+ vaultCategory: m.vault_category || null,
103
+ vaultedAt: m.vaulted_at || null,
104
+ aiPositioning: m.ai_positioning || null,
105
+ aiTakeaway: m.ai_takeaway || [],
106
+ }
107
+ }
78
108
 
79
109
  // ---------------------------------------------------------------------------
80
- // Tool: get_bookmark
110
+ // Tool handlers
81
111
  // ---------------------------------------------------------------------------
82
112
 
83
- server.tool(
84
- 'get_bookmark',
85
- 'Get full details of a single bookmark including AI analysis and content',
86
- {
87
- id: z.string().describe('Bookmark UUID'),
88
- },
89
- async ({ id }) => {
90
- const { data, error } = await supabase
113
+ async function handleSearchVault(args: { query: string; limit?: number }) {
114
+ const { query, limit } = args
115
+ const { data, error } = await supabase
116
+ .from('bookmarks')
117
+ .select('*')
118
+ .eq('status', 'absorbed')
119
+ .ilike('title', `%${query}%`)
120
+ .order('created_at', { ascending: false })
121
+ .limit(limit || 10)
122
+
123
+ if (error) return textResult(`Error: ${error.message}`)
124
+
125
+ // Also search in content_metadata tags and takeaway
126
+ let results = (data || []).map(metaSummary)
127
+
128
+ // If title search returned few results, also search by tag
129
+ if (results.length < (limit || 10)) {
130
+ const { data: tagData } = await supabase
91
131
  .from('bookmarks')
92
132
  .select('*')
93
- .eq('id', id)
94
- .single()
95
-
96
- if (error) {
97
- return { content: [{ type: 'text' as const, text: `Error: ${error.message}` }] }
133
+ .eq('status', 'absorbed')
134
+ .order('created_at', { ascending: false })
135
+ .limit(50)
136
+
137
+ if (tagData) {
138
+ const existingIds = new Set(results.map((r: any) => r.id))
139
+ const tagMatches = tagData
140
+ .filter((row: any) => {
141
+ if (existingIds.has(row.id)) return false
142
+ const m = row.content_metadata || {}
143
+ const tags = (m.tags || []) as string[]
144
+ const takeaway = (m.ai_takeaway || []) as string[]
145
+ const positioning = m.ai_positioning || ''
146
+ const allText = [...tags, ...takeaway, positioning].join(' ').toLowerCase()
147
+ return allText.includes(query.toLowerCase())
148
+ })
149
+ .map(metaSummary)
150
+
151
+ results = [...results, ...tagMatches].slice(0, limit || 10)
98
152
  }
153
+ }
99
154
 
100
- return {
101
- content: [{
102
- type: 'text' as const,
103
- text: JSON.stringify(data, null, 2),
104
- }],
155
+ return textResult(JSON.stringify(results, null, 2))
156
+ }
157
+
158
+ async function handleGetBookmark(args: { id: string }) {
159
+ const { data, error } = await supabase
160
+ .from('bookmarks')
161
+ .select('*')
162
+ .eq('id', args.id)
163
+ .single()
164
+
165
+ if (error) return textResult(`Error: ${error.message}`)
166
+ return textResult(JSON.stringify(meta(data), null, 2))
167
+ }
168
+
169
+ async function handleListCategories() {
170
+ const { data, error } = await supabase
171
+ .from('bookmarks')
172
+ .select('content_metadata')
173
+ .eq('status', 'absorbed')
174
+
175
+ if (error) return textResult(`Error: ${error.message}`)
176
+
177
+ const counts: Record<string, number> = {}
178
+ for (const row of data || []) {
179
+ const cat = (row.content_metadata as any)?.vault_category || 'Uncategorized'
180
+ counts[cat] = (counts[cat] || 0) + 1
181
+ }
182
+
183
+ const categories = Object.entries(counts)
184
+ .map(([category, count]) => ({ category, count }))
185
+ .sort((a, b) => b.count - a.count)
186
+
187
+ return textResult(JSON.stringify(categories, null, 2))
188
+ }
189
+
190
+ async function handleGetCollections() {
191
+ const { data, error } = await supabase
192
+ .from('collections')
193
+ .select('id, name, bookmark_ids, ai_overview')
194
+
195
+ if (error) return textResult(`Error: ${error.message}`)
196
+
197
+ const collections = (data || []).map((c: any) => ({
198
+ id: c.id,
199
+ name: c.name,
200
+ articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
201
+ overview: c.ai_overview?.theme || null,
202
+ }))
203
+
204
+ return textResult(JSON.stringify(collections, null, 2))
205
+ }
206
+
207
+ async function handleGetCollectionOverview(args: { name: string }) {
208
+ const { data: collection, error } = await supabase
209
+ .from('collections')
210
+ .select('*')
211
+ .eq('name', args.name)
212
+ .single()
213
+
214
+ if (error) {
215
+ return textResult(
216
+ error.code === 'PGRST116'
217
+ ? `No collection found with name "${args.name}".`
218
+ : `Error: ${error.message}`
219
+ )
220
+ }
221
+
222
+ let bookmarks: any[] = []
223
+ if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
224
+ const { data: bData, error: bError } = await supabase
225
+ .from('bookmarks')
226
+ .select('*')
227
+ .in('id', collection.bookmark_ids)
228
+
229
+ if (!bError && bData) {
230
+ bookmarks = bData.map(metaSummary)
105
231
  }
106
232
  }
107
- )
233
+
234
+ return textResult(JSON.stringify({
235
+ id: collection.id,
236
+ name: collection.name,
237
+ articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
238
+ aiOverview: collection.ai_overview,
239
+ bookmarks,
240
+ }, null, 2))
241
+ }
242
+
243
+ async function handleGetArticleContent(args: { id: string }) {
244
+ const { data, error } = await supabase
245
+ .from('bookmarks')
246
+ .select('*')
247
+ .eq('id', args.id)
248
+ .single()
249
+
250
+ if (error) return textResult(`Error: ${error.message}`)
251
+ return textResult(JSON.stringify(meta(data), null, 2))
252
+ }
108
253
 
109
254
  // ---------------------------------------------------------------------------
110
- // Tool: list_categories
255
+ // Vercel API base URL for content fetching
111
256
  // ---------------------------------------------------------------------------
112
257
 
113
- server.tool(
114
- 'list_categories',
115
- 'List all Vault categories with article counts',
116
- {},
117
- async () => {
118
- const { data, error } = await supabase
119
- .from('bookmarks')
120
- .select('vault_category')
121
- .eq('status', 'vault')
258
+ const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud'
259
+ const API_KEY = 'burn451-2026-secret-key'
260
+
261
+ /** Detect platform from URL */
262
+ function detectPlatform(url: string): string {
263
+ if (/x\.com|twitter\.com/i.test(url)) return 'x'
264
+ if (/youtube\.com|youtu\.be/i.test(url)) return 'youtube'
265
+ if (/reddit\.com|redd\.it/i.test(url)) return 'reddit'
266
+ if (/bilibili\.com|b23\.tv/i.test(url)) return 'bilibili'
267
+ if (/open\.spotify\.com/i.test(url)) return 'spotify'
268
+ if (/mp\.weixin\.qq\.com/i.test(url)) return 'wechat'
269
+ if (/xiaohongshu\.com|xhslink\.com/i.test(url)) return 'xhs'
270
+ return 'web'
271
+ }
122
272
 
123
- if (error) {
124
- return { content: [{ type: 'text' as const, text: `Error: ${error.message}` }] }
273
+ /** Fetch content via Vercel API proxy (bypasses GFW for X.com, etc.) */
274
+ async function fetchViaAPI(url: string, platform: string): Promise<{ title?: string; author?: string; content?: string; error?: string }> {
275
+ try {
276
+ let endpoint: string
277
+ let params: string
278
+
279
+ switch (platform) {
280
+ case 'x':
281
+ endpoint = `${API_BASE}/api/parse-x`
282
+ params = `url=${encodeURIComponent(url)}`
283
+ break
284
+ case 'reddit':
285
+ endpoint = `${API_BASE}/api/parse-reddit`
286
+ params = `url=${encodeURIComponent(url)}`
287
+ break
288
+ case 'spotify':
289
+ endpoint = `${API_BASE}/api/parse-meta`
290
+ params = `url=${encodeURIComponent(url)}&_platform=spotify`
291
+ break
292
+ case 'wechat':
293
+ case 'xhs':
294
+ endpoint = `${API_BASE}/api/parse-meta`
295
+ params = `url=${encodeURIComponent(url)}&_platform=${platform}`
296
+ break
297
+ case 'youtube':
298
+ // Try transcript extraction via jina-extract with youtube platform hint
299
+ endpoint = `${API_BASE}/api/jina-extract`
300
+ params = `url=${encodeURIComponent(url)}&_platform=youtube`
301
+ break
302
+ default:
303
+ endpoint = `${API_BASE}/api/jina-extract`
304
+ params = `url=${encodeURIComponent(url)}`
305
+ break
125
306
  }
126
307
 
127
- // Group by category and count
128
- const counts: Record<string, number> = {}
129
- for (const row of data || []) {
130
- const cat = row.vault_category || 'Uncategorized'
131
- counts[cat] = (counts[cat] || 0) + 1
308
+ const resp = await fetch(`${endpoint}?${params}`, {
309
+ headers: {
310
+ 'x-api-key': API_KEY,
311
+ 'Accept': 'application/json',
312
+ },
313
+ signal: AbortSignal.timeout(30000),
314
+ })
315
+
316
+ if (!resp.ok) {
317
+ return { error: `API returned ${resp.status}` }
132
318
  }
133
319
 
134
- const categories = Object.entries(counts)
135
- .map(([category, count]) => ({ category, count }))
136
- .sort((a, b) => b.count - a.count)
320
+ const data = await resp.json() as any
137
321
 
138
- return {
139
- content: [{
140
- type: 'text' as const,
141
- text: JSON.stringify(categories, null, 2),
142
- }],
322
+ // Normalize response across different API endpoints
323
+ if (platform === 'x') {
324
+ const text = data.text || data.article_text || ''
325
+ const quoteText = data.quote ? `\n\n[Quote from @${data.quote.handle}]: ${data.quote.text}` : ''
326
+ return {
327
+ title: data.text?.slice(0, 100) || 'Tweet',
328
+ author: data.author ? `@${data.handle || data.author}` : undefined,
329
+ content: text + quoteText,
330
+ }
143
331
  }
144
- }
145
- )
146
-
147
- // ---------------------------------------------------------------------------
148
- // Tool: get_clusters
149
- // ---------------------------------------------------------------------------
150
332
 
151
- server.tool(
152
- 'get_clusters',
153
- 'Get AI-generated topic clusters from your Vault',
154
- {},
155
- async () => {
156
- const { data, error } = await supabase
157
- .from('vault_clusters')
158
- .select('clusters, generated_at, stale')
159
- .single()
333
+ if (platform === 'spotify') {
334
+ return {
335
+ title: data.title,
336
+ author: data.author,
337
+ content: data.extracted_content || data.description || null,
338
+ }
339
+ }
160
340
 
161
- if (error) {
341
+ if (platform === 'wechat') {
342
+ // WeChat parse-meta returns extracted_content from js_content div
162
343
  return {
163
- content: [{
164
- type: 'text' as const,
165
- text: error.code === 'PGRST116'
166
- ? 'No clusters generated yet. Open the Vault tab in Burn to generate clusters.'
167
- : `Error: ${error.message}`,
168
- }],
344
+ title: data.title,
345
+ author: data.author,
346
+ content: data.extracted_content || data.content || null,
169
347
  }
170
348
  }
171
349
 
172
350
  return {
173
- content: [{
174
- type: 'text' as const,
175
- text: JSON.stringify({
176
- clusters: data.clusters,
177
- generatedAt: data.generated_at,
178
- isStale: data.stale,
179
- }, null, 2),
180
- }],
351
+ title: data.title,
352
+ author: data.author,
353
+ content: data.content || data.extracted_content || data.text || data.transcript || null,
181
354
  }
355
+ } catch (err: any) {
356
+ return { error: err.message || 'Fetch failed' }
182
357
  }
183
- )
358
+ }
184
359
 
185
- // ---------------------------------------------------------------------------
186
- // Tool: get_cluster_digest
187
- // ---------------------------------------------------------------------------
360
+ async function handleFetchContent(args: { url: string }) {
361
+ const { url } = args
362
+ const platform = detectPlatform(url)
363
+
364
+ // Step 1: Check if we already have content in Supabase
365
+ const { data: existing } = await supabase
366
+ .from('bookmarks')
367
+ .select('*')
368
+ .eq('url', url)
369
+ .limit(1)
370
+ .maybeSingle()
371
+
372
+ if (existing) {
373
+ const m = existing.content_metadata || {}
374
+ if (m.extracted_content && m.extracted_content.length > 50) {
375
+ return textResult(JSON.stringify({
376
+ source: 'cache',
377
+ url,
378
+ platform,
379
+ title: existing.title,
380
+ author: m.author,
381
+ content: m.extracted_content,
382
+ aiPositioning: m.ai_positioning,
383
+ aiTakeaway: m.ai_takeaway,
384
+ tags: m.tags,
385
+ }, null, 2))
386
+ }
387
+ }
188
388
 
189
- server.tool(
190
- 'get_cluster_digest',
191
- 'Get the AI-generated digest (summary, insights, relationships) for a topic cluster',
192
- {
193
- clusterName: z.string().describe('Name of the cluster'),
194
- },
195
- async ({ clusterName }) => {
196
- const { data, error } = await supabase
197
- .from('cluster_digests')
198
- .select('digest, generated_at')
199
- .eq('cluster_name', clusterName)
200
- .single()
389
+ // Step 2: Fetch fresh content via Vercel API
390
+ const result = await fetchViaAPI(url, platform)
391
+
392
+ if (result.error) {
393
+ return textResult(JSON.stringify({
394
+ source: 'error',
395
+ url,
396
+ platform,
397
+ error: result.error,
398
+ hint: platform === 'x' ? 'X.com content is fetched via Vercel Edge proxy to bypass GFW' : undefined,
399
+ }, null, 2))
400
+ }
201
401
 
202
- if (error) {
203
- return {
204
- content: [{
205
- type: 'text' as const,
206
- text: error.code === 'PGRST116'
207
- ? `No digest found for cluster "${clusterName}". Open the cluster in Burn App to generate a digest first.`
208
- : `Error: ${error.message}`,
209
- }],
210
- }
211
- }
402
+ return textResult(JSON.stringify({
403
+ source: 'live',
404
+ url,
405
+ platform,
406
+ title: result.title,
407
+ author: result.author,
408
+ content: result.content,
409
+ }, null, 2))
410
+ }
411
+
412
+ async function handleListSparks(args: { limit?: number }) {
413
+ const { data, error } = await supabase
414
+ .from('bookmarks')
415
+ .select('*')
416
+ .eq('status', 'read')
417
+ .order('created_at', { ascending: false })
418
+ .limit(args.limit || 20)
419
+
420
+ if (error) return textResult(`Error: ${error.message}`)
212
421
 
422
+ const results = (data || []).map((row: any) => {
423
+ const s = metaSummary(row)
424
+ const m = row.content_metadata || {}
213
425
  return {
214
- content: [{
215
- type: 'text' as const,
216
- text: JSON.stringify({
217
- ...data.digest,
218
- generatedAt: data.generated_at,
219
- }, null, 2),
220
- }],
426
+ ...s,
427
+ sparkInsight: m.spark_insight || null,
428
+ sparkExpiresAt: m.spark_expires_at || null,
221
429
  }
430
+ })
431
+
432
+ return textResult(JSON.stringify(results, null, 2))
433
+ }
434
+
435
+ async function handleSearchSparks(args: { query: string; limit?: number }) {
436
+ const { query, limit } = args
437
+ const { data, error } = await supabase
438
+ .from('bookmarks')
439
+ .select('*')
440
+ .eq('status', 'read')
441
+ .order('created_at', { ascending: false })
442
+ .limit(50)
443
+
444
+ if (error) return textResult(`Error: ${error.message}`)
445
+
446
+ const results = (data || [])
447
+ .filter((row: any) => {
448
+ const m = row.content_metadata || {}
449
+ const searchable = [
450
+ row.title || '',
451
+ ...(m.tags || []),
452
+ ...(m.ai_takeaway || []),
453
+ m.ai_positioning || '',
454
+ m.spark_insight || '',
455
+ ].join(' ').toLowerCase()
456
+ return searchable.includes(query.toLowerCase())
457
+ })
458
+ .slice(0, limit || 10)
459
+ .map((row: any) => {
460
+ const s = metaSummary(row)
461
+ const m = row.content_metadata || {}
462
+ return {
463
+ ...s,
464
+ sparkInsight: m.spark_insight || null,
465
+ sparkExpiresAt: m.spark_expires_at || null,
466
+ }
467
+ })
468
+
469
+ return textResult(JSON.stringify(results, null, 2))
470
+ }
471
+
472
+ async function handleListVault(args: { limit?: number; category?: string }) {
473
+ let query = supabase
474
+ .from('bookmarks')
475
+ .select('*')
476
+ .eq('status', 'absorbed')
477
+ .order('created_at', { ascending: false })
478
+ .limit(args.limit || 20)
479
+
480
+ const { data, error } = await query
481
+
482
+ if (error) return textResult(`Error: ${error.message}`)
483
+
484
+ let results = (data || []).map(metaSummary)
485
+
486
+ // Filter by category if provided
487
+ if (args.category) {
488
+ results = results.filter((r: any) =>
489
+ r.vaultCategory?.toLowerCase() === args.category!.toLowerCase()
490
+ )
222
491
  }
492
+
493
+ return textResult(JSON.stringify(results, null, 2))
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Register tools
498
+ // ---------------------------------------------------------------------------
499
+
500
+ // @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
501
+ server.tool(
502
+ 'search_vault',
503
+ 'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)',
504
+ { query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
505
+ handleSearchVault
506
+ )
507
+
508
+ server.tool(
509
+ 'list_vault',
510
+ 'List bookmarks in your Vault, optionally filtered by category',
511
+ { limit: z.number().optional().describe('Max results (default 20)'), category: z.string().optional().describe('Filter by vault category') },
512
+ handleListVault
513
+ )
514
+
515
+ server.tool(
516
+ 'list_sparks',
517
+ 'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.',
518
+ { limit: z.number().optional().describe('Max results (default 20)') },
519
+ handleListSparks
520
+ )
521
+
522
+ server.tool(
523
+ 'search_sparks',
524
+ 'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)',
525
+ { query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
526
+ handleSearchSparks
527
+ )
528
+
529
+ server.tool(
530
+ 'get_bookmark',
531
+ 'Get full details of a single bookmark including AI analysis and extracted content',
532
+ { id: z.string().describe('Bookmark UUID') },
533
+ handleGetBookmark
534
+ )
535
+
536
+ server.tool(
537
+ 'get_article_content',
538
+ 'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)',
539
+ { id: z.string().describe('Bookmark UUID') },
540
+ handleGetArticleContent
541
+ )
542
+
543
+ server.tool(
544
+ 'fetch_content',
545
+ '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.',
546
+ { url: z.string().describe('The URL to fetch content from') },
547
+ handleFetchContent
548
+ )
549
+
550
+ server.tool(
551
+ 'list_categories',
552
+ 'List all Vault categories with article counts',
553
+ {},
554
+ handleListCategories
555
+ )
556
+
557
+ server.tool(
558
+ 'get_collections',
559
+ 'List all your Collections with article counts and AI overview themes',
560
+ {},
561
+ handleGetCollections
562
+ )
563
+
564
+ server.tool(
565
+ 'get_collection_overview',
566
+ 'Get a Collection by name with its AI overview and linked bookmarks metadata',
567
+ { name: z.string().describe('Collection name') },
568
+ handleGetCollectionOverview
223
569
  )
224
570
 
225
571
  // ---------------------------------------------------------------------------
@@ -232,9 +578,9 @@ server.resource(
232
578
  async (uri) => {
233
579
  const { data, error } = await supabase
234
580
  .from('bookmarks')
235
- .select('id, url, title, author, ai_positioning, tags, vault_category, vaulted_at')
236
- .eq('status', 'vault')
237
- .order('vaulted_at', { ascending: false })
581
+ .select('*')
582
+ .eq('status', 'absorbed')
583
+ .order('created_at', { ascending: false })
238
584
 
239
585
  if (error) {
240
586
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
@@ -244,7 +590,7 @@ server.resource(
244
590
  contents: [{
245
591
  uri: uri.href,
246
592
  mimeType: 'application/json',
247
- text: JSON.stringify(data, null, 2),
593
+ text: JSON.stringify((data || []).map(metaSummary), null, 2),
248
594
  }],
249
595
  }
250
596
  }
@@ -260,8 +606,8 @@ server.resource(
260
606
  async (uri) => {
261
607
  const { data, error } = await supabase
262
608
  .from('bookmarks')
263
- .select('vault_category')
264
- .eq('status', 'vault')
609
+ .select('content_metadata')
610
+ .eq('status', 'absorbed')
265
611
 
266
612
  if (error) {
267
613
  return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] }
@@ -269,7 +615,7 @@ server.resource(
269
615
 
270
616
  const counts: Record<string, number> = {}
271
617
  for (const row of data || []) {
272
- const cat = row.vault_category || 'Uncategorized'
618
+ const cat = (row.content_metadata as any)?.vault_category || 'Uncategorized'
273
619
  counts[cat] = (counts[cat] || 0) + 1
274
620
  }
275
621