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.
- package/dist/index.js +394 -112
- package/package.json +1 -1
- 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,
|
|
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.
|
|
38
|
+
version: '1.1.0',
|
|
38
39
|
});
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
|
-
//
|
|
41
|
+
// Helper: standard text result
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
98
|
+
// Tool handlers
|
|
65
99
|
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
|
|
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('
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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('
|
|
91
|
-
.eq('
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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('
|
|
117
|
-
.select('
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
221
|
+
// Vercel API base URL for content fetching
|
|
142
222
|
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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('
|
|
148
|
-
.select('
|
|
149
|
-
.eq('
|
|
150
|
-
.
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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('
|
|
178
|
-
.eq('status', '
|
|
179
|
-
.order('
|
|
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('
|
|
198
|
-
.eq('status', '
|
|
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
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { McpServer
|
|
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,
|
|
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.
|
|
44
|
+
version: '1.1.0',
|
|
44
45
|
})
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
47
|
-
//
|
|
48
|
+
// Helper: standard text result
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helper: extract fields from content_metadata JSONB
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
69
58
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
110
|
+
// Tool handlers
|
|
81
111
|
// ---------------------------------------------------------------------------
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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('
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
//
|
|
255
|
+
// Vercel API base URL for content fetching
|
|
111
256
|
// ---------------------------------------------------------------------------
|
|
112
257
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
341
|
+
if (platform === 'wechat') {
|
|
342
|
+
// WeChat parse-meta returns extracted_content from js_content div
|
|
162
343
|
return {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
.
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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('
|
|
236
|
-
.eq('status', '
|
|
237
|
-
.order('
|
|
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('
|
|
264
|
-
.eq('status', '
|
|
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
|
|