burn-mcp-server 2.0.6 → 2.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/.cursor-plugin/plugin.json +19 -0
- package/.mcp.json +11 -0
- package/README.md +30 -1
- package/api/mcp.js +36075 -0
- package/dist/http.mjs +1131 -0
- package/dist/index.js +1021 -1009
- package/package.json +1 -1
- package/server.json +16 -2
- package/src/vercel-node-handler.ts +76 -0
- package/vercel.json +11 -0
package/dist/http.mjs
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
// src/http-dev.ts
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
// src/http.ts
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
|
+
import { createClient } from "@supabase/supabase-js";
|
|
7
|
+
|
|
8
|
+
// src/lib/auth.ts
|
|
9
|
+
var DEFAULT_EXCHANGE_URL = "https://api.burn451.cloud/api/mcp-exchange";
|
|
10
|
+
async function exchangeToken(mcpToken, exchangeUrl = process.env.BURN_MCP_EXCHANGE_URL || DEFAULT_EXCHANGE_URL) {
|
|
11
|
+
const resp = await fetch(exchangeUrl, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ token: mcpToken })
|
|
15
|
+
});
|
|
16
|
+
if (!resp.ok) {
|
|
17
|
+
let detail = "";
|
|
18
|
+
try {
|
|
19
|
+
detail = JSON.stringify(await resp.json());
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Token exchange failed (${resp.status}): ${detail}`);
|
|
23
|
+
}
|
|
24
|
+
const data = await resp.json();
|
|
25
|
+
if (!data.access_token || !data.refresh_token) {
|
|
26
|
+
throw new Error("Token exchange succeeded but returned no session tokens");
|
|
27
|
+
}
|
|
28
|
+
return { access_token: data.access_token, refresh_token: data.refresh_token };
|
|
29
|
+
}
|
|
30
|
+
async function applySession(supabase, session, onRefresh) {
|
|
31
|
+
const { error } = await supabase.auth.setSession(session);
|
|
32
|
+
if (error) throw new Error(`Failed to set session: ${error.message}`);
|
|
33
|
+
if (onRefresh) {
|
|
34
|
+
supabase.auth.onAuthStateChange((_event, s) => {
|
|
35
|
+
if (s?.access_token && s?.refresh_token) {
|
|
36
|
+
onRefresh({ access_token: s.access_token, refresh_token: s.refresh_token });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
var httpSessionCache = /* @__PURE__ */ new Map();
|
|
42
|
+
var HTTP_CACHE_TTL_MS = 5 * 6e4;
|
|
43
|
+
async function getOrExchangeSession(mcpToken) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const cached = httpSessionCache.get(mcpToken);
|
|
46
|
+
if (cached && cached.expiresAt > now) return cached.session;
|
|
47
|
+
const session = await exchangeToken(mcpToken);
|
|
48
|
+
httpSessionCache.set(mcpToken, { session, expiresAt: now + HTTP_CACHE_TTL_MS });
|
|
49
|
+
return session;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/setup.ts
|
|
53
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
54
|
+
import { z } from "zod";
|
|
55
|
+
function createBurnServer(supabase, opts = {}) {
|
|
56
|
+
const RATE_LIMIT_WINDOW_MS = 6e4;
|
|
57
|
+
const RATE_LIMIT_MAX_CALLS = opts.rateLimitPerMin ?? 30;
|
|
58
|
+
const rateLimitLog = [];
|
|
59
|
+
function checkRateLimit() {
|
|
60
|
+
if (RATE_LIMIT_MAX_CALLS === 0) return null;
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
63
|
+
rateLimitLog.shift();
|
|
64
|
+
}
|
|
65
|
+
if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
|
|
66
|
+
const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1e3);
|
|
67
|
+
return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`;
|
|
68
|
+
}
|
|
69
|
+
rateLimitLog.push(now);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const server2 = new McpServer({
|
|
73
|
+
name: opts.name || "burn-mcp-server",
|
|
74
|
+
version: opts.version || "2.0.7"
|
|
75
|
+
});
|
|
76
|
+
function textResult(text) {
|
|
77
|
+
return { content: [{ type: "text", text }] };
|
|
78
|
+
}
|
|
79
|
+
async function verifyBookmark(id, expectedStatus) {
|
|
80
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("id", id).single();
|
|
81
|
+
if (error) return { data: null, error: error.code === "PGRST116" ? "Bookmark not found" : error.message };
|
|
82
|
+
if (expectedStatus) {
|
|
83
|
+
const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
|
|
84
|
+
if (!allowed.includes(data.status)) {
|
|
85
|
+
const statusLabels = { active: "Flame", read: "Spark", absorbed: "Vault", ash: "Ash" };
|
|
86
|
+
return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map((s) => statusLabels[s] || s).join(" or ")})` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { data, error: null };
|
|
90
|
+
}
|
|
91
|
+
async function mergeContentMetadata(bookmarkId, fields, extraColumns) {
|
|
92
|
+
const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("id", bookmarkId).single();
|
|
93
|
+
if (error) return { error: error.message };
|
|
94
|
+
const existing = data.content_metadata || {};
|
|
95
|
+
const cleaned = {};
|
|
96
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
97
|
+
if (v !== void 0 && v !== null) cleaned[k] = v;
|
|
98
|
+
}
|
|
99
|
+
const merged = { ...existing, ...cleaned };
|
|
100
|
+
const { error: updateError } = await supabase.from("bookmarks").update({ content_metadata: merged, ...extraColumns }).eq("id", bookmarkId);
|
|
101
|
+
return { error: updateError?.message || null };
|
|
102
|
+
}
|
|
103
|
+
function meta(row) {
|
|
104
|
+
const m = row.content_metadata || {};
|
|
105
|
+
return {
|
|
106
|
+
id: row.id,
|
|
107
|
+
url: row.url,
|
|
108
|
+
title: row.title,
|
|
109
|
+
author: m.author || row.author || null,
|
|
110
|
+
platform: row.platform,
|
|
111
|
+
status: row.status,
|
|
112
|
+
tags: m.tags || [],
|
|
113
|
+
thumbnail: m.thumbnail || null,
|
|
114
|
+
vaultCategory: m.vault_category || null,
|
|
115
|
+
vaultedAt: m.vaulted_at || null,
|
|
116
|
+
aiPositioning: m.ai_positioning || null,
|
|
117
|
+
aiDensity: m.ai_density || null,
|
|
118
|
+
aiMinutes: m.ai_minutes || null,
|
|
119
|
+
aiTakeaway: m.ai_takeaway || [],
|
|
120
|
+
aiStrategyReason: m.ai_strategy_reason || null,
|
|
121
|
+
aiHowToRead: m.ai_how_to_read || null,
|
|
122
|
+
aiOverlap: m.ai_overlap || null,
|
|
123
|
+
aiVerdict: m.ai_verdict || null,
|
|
124
|
+
aiSummary: m.ai_summary || null,
|
|
125
|
+
sparkInsight: m.spark_insight || null,
|
|
126
|
+
extractedContent: m.extracted_content || null,
|
|
127
|
+
externalURL: m.external_url || null,
|
|
128
|
+
aiRelevance: m.ai_relevance || null,
|
|
129
|
+
aiNovelty: m.ai_novelty || null,
|
|
130
|
+
createdAt: row.created_at,
|
|
131
|
+
countdownExpiresAt: row.countdown_expires_at,
|
|
132
|
+
readAt: row.read_at
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function metaSummary(row) {
|
|
136
|
+
const m = row.content_metadata || {};
|
|
137
|
+
return {
|
|
138
|
+
id: row.id,
|
|
139
|
+
url: row.url,
|
|
140
|
+
title: row.title,
|
|
141
|
+
author: m.author || null,
|
|
142
|
+
platform: row.platform,
|
|
143
|
+
tags: m.tags || [],
|
|
144
|
+
vaultCategory: m.vault_category || null,
|
|
145
|
+
vaultedAt: m.vaulted_at || null,
|
|
146
|
+
aiPositioning: m.ai_positioning || null,
|
|
147
|
+
aiTakeaway: m.ai_takeaway || []
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function flameSummary(row) {
|
|
151
|
+
const m = row.content_metadata || {};
|
|
152
|
+
const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null;
|
|
153
|
+
const now = /* @__PURE__ */ new Date();
|
|
154
|
+
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
|
|
155
|
+
const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
url: row.url,
|
|
159
|
+
title: row.title,
|
|
160
|
+
author: m.author || null,
|
|
161
|
+
platform: row.platform,
|
|
162
|
+
tags: m.tags || [],
|
|
163
|
+
createdAt: row.created_at,
|
|
164
|
+
expiresAt: row.countdown_expires_at,
|
|
165
|
+
remainingHours,
|
|
166
|
+
isBurning: remainingHours <= 6,
|
|
167
|
+
isCritical: remainingHours <= 1,
|
|
168
|
+
aiPositioning: m.ai_positioning || null,
|
|
169
|
+
aiDensity: m.ai_density || null,
|
|
170
|
+
aiMinutes: m.ai_minutes || null,
|
|
171
|
+
aiTakeaway: m.ai_takeaway || [],
|
|
172
|
+
aiStrategy: m.ai_strategy || null,
|
|
173
|
+
aiStrategyReason: m.ai_strategy_reason || null,
|
|
174
|
+
aiHowToRead: m.ai_how_to_read || null,
|
|
175
|
+
aiRelevance: m.ai_relevance || null,
|
|
176
|
+
aiNovelty: m.ai_novelty || null,
|
|
177
|
+
aiOverlap: m.ai_overlap || null,
|
|
178
|
+
aiHook: m.ai_hook || null,
|
|
179
|
+
aiAbout: m.ai_about || []
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function rateLimited(handler) {
|
|
183
|
+
return async (args) => {
|
|
184
|
+
const err = checkRateLimit();
|
|
185
|
+
if (err) return textResult(err);
|
|
186
|
+
return handler(args);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function handleSearchVault(args) {
|
|
190
|
+
const { query, limit } = args;
|
|
191
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").ilike("title", `%${query}%`).order("created_at", { ascending: false }).limit(limit || 10);
|
|
192
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
193
|
+
let results = (data || []).map(metaSummary);
|
|
194
|
+
if (results.length < (limit || 10)) {
|
|
195
|
+
const { data: tagData } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false }).limit(50);
|
|
196
|
+
if (tagData) {
|
|
197
|
+
const existingIds = new Set(results.map((r) => r.id));
|
|
198
|
+
const tagMatches = tagData.filter((row) => {
|
|
199
|
+
if (existingIds.has(row.id)) return false;
|
|
200
|
+
const m = row.content_metadata || {};
|
|
201
|
+
const tags = m.tags || [];
|
|
202
|
+
const takeaway = m.ai_takeaway || [];
|
|
203
|
+
const positioning = m.ai_positioning || "";
|
|
204
|
+
const allText = [...tags, ...takeaway, positioning].join(" ").toLowerCase();
|
|
205
|
+
return allText.includes(query.toLowerCase());
|
|
206
|
+
}).map(metaSummary);
|
|
207
|
+
results = [...results, ...tagMatches].slice(0, limit || 10);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
211
|
+
}
|
|
212
|
+
async function handleGetBookmark(args) {
|
|
213
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).single();
|
|
214
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
215
|
+
return textResult(JSON.stringify(meta(data), null, 2));
|
|
216
|
+
}
|
|
217
|
+
async function handleListCategories() {
|
|
218
|
+
const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("status", "absorbed");
|
|
219
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
220
|
+
const counts = {};
|
|
221
|
+
for (const row of data || []) {
|
|
222
|
+
const cat = row.content_metadata?.vault_category || "Uncategorized";
|
|
223
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
224
|
+
}
|
|
225
|
+
const categories = Object.entries(counts).map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
|
|
226
|
+
return textResult(JSON.stringify(categories, null, 2));
|
|
227
|
+
}
|
|
228
|
+
async function handleGetCollections() {
|
|
229
|
+
const { data, error } = await supabase.from("collections").select("id, name, bookmark_ids, ai_overview");
|
|
230
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
231
|
+
const collections = (data || []).map((c) => ({
|
|
232
|
+
id: c.id,
|
|
233
|
+
name: c.name,
|
|
234
|
+
articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
|
|
235
|
+
overview: c.ai_overview?.theme || null
|
|
236
|
+
}));
|
|
237
|
+
return textResult(JSON.stringify(collections, null, 2));
|
|
238
|
+
}
|
|
239
|
+
async function handleGetCollectionOverview(args) {
|
|
240
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("name", args.name).single();
|
|
241
|
+
if (error) {
|
|
242
|
+
return textResult(
|
|
243
|
+
error.code === "PGRST116" ? `No collection found with name "${args.name}".` : `Error: ${error.message}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
let bookmarks = [];
|
|
247
|
+
if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
|
|
248
|
+
const { data: bData, error: bError } = await supabase.from("bookmarks").select("*").in("id", collection.bookmark_ids);
|
|
249
|
+
if (!bError && bData) {
|
|
250
|
+
bookmarks = bData.map(metaSummary);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return textResult(JSON.stringify({
|
|
254
|
+
id: collection.id,
|
|
255
|
+
name: collection.name,
|
|
256
|
+
articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
|
|
257
|
+
aiOverview: collection.ai_overview,
|
|
258
|
+
bookmarks
|
|
259
|
+
}, null, 2));
|
|
260
|
+
}
|
|
261
|
+
async function handleGetArticleContent(args) {
|
|
262
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).single();
|
|
263
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
264
|
+
return textResult(JSON.stringify(meta(data), null, 2));
|
|
265
|
+
}
|
|
266
|
+
const API_BASE = process.env.BURN_API_URL || "https://api.burn451.cloud";
|
|
267
|
+
const API_KEY = process.env.BURN_API_KEY;
|
|
268
|
+
function detectPlatform(url) {
|
|
269
|
+
if (/x\.com|twitter\.com/i.test(url)) return "x";
|
|
270
|
+
if (/youtube\.com|youtu\.be/i.test(url)) return "youtube";
|
|
271
|
+
if (/reddit\.com|redd\.it/i.test(url)) return "reddit";
|
|
272
|
+
if (/bilibili\.com|b23\.tv/i.test(url)) return "bilibili";
|
|
273
|
+
if (/open\.spotify\.com/i.test(url)) return "spotify";
|
|
274
|
+
if (/mp\.weixin\.qq\.com/i.test(url)) return "wechat";
|
|
275
|
+
if (/xiaohongshu\.com|xhslink\.com/i.test(url)) return "xhs";
|
|
276
|
+
return "web";
|
|
277
|
+
}
|
|
278
|
+
async function fetchViaAPI(url, platform) {
|
|
279
|
+
try {
|
|
280
|
+
let endpoint;
|
|
281
|
+
let params;
|
|
282
|
+
switch (platform) {
|
|
283
|
+
case "x":
|
|
284
|
+
endpoint = `${API_BASE}/api/parse-x`;
|
|
285
|
+
params = `url=${encodeURIComponent(url)}`;
|
|
286
|
+
break;
|
|
287
|
+
case "reddit":
|
|
288
|
+
endpoint = `${API_BASE}/api/parse-reddit`;
|
|
289
|
+
params = `url=${encodeURIComponent(url)}`;
|
|
290
|
+
break;
|
|
291
|
+
case "spotify":
|
|
292
|
+
endpoint = `${API_BASE}/api/parse-meta`;
|
|
293
|
+
params = `url=${encodeURIComponent(url)}&_platform=spotify`;
|
|
294
|
+
break;
|
|
295
|
+
case "wechat":
|
|
296
|
+
case "xhs":
|
|
297
|
+
endpoint = `${API_BASE}/api/parse-meta`;
|
|
298
|
+
params = `url=${encodeURIComponent(url)}&_platform=${platform}`;
|
|
299
|
+
break;
|
|
300
|
+
case "youtube":
|
|
301
|
+
endpoint = `${API_BASE}/api/jina-extract`;
|
|
302
|
+
params = `url=${encodeURIComponent(url)}&_platform=youtube`;
|
|
303
|
+
break;
|
|
304
|
+
default:
|
|
305
|
+
endpoint = `${API_BASE}/api/jina-extract`;
|
|
306
|
+
params = `url=${encodeURIComponent(url)}`;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
const resp = await fetch(`${endpoint}?${params}`, {
|
|
310
|
+
headers: {
|
|
311
|
+
...API_KEY ? { "x-api-key": API_KEY } : {},
|
|
312
|
+
"Accept": "application/json"
|
|
313
|
+
},
|
|
314
|
+
signal: AbortSignal.timeout(3e4)
|
|
315
|
+
});
|
|
316
|
+
if (!resp.ok) {
|
|
317
|
+
return { error: `API returned ${resp.status}` };
|
|
318
|
+
}
|
|
319
|
+
const data = await resp.json();
|
|
320
|
+
if (platform === "x") {
|
|
321
|
+
const text = data.text || data.article_text || "";
|
|
322
|
+
const quoteText = data.quote ? `
|
|
323
|
+
|
|
324
|
+
[Quote from @${data.quote.handle}]: ${data.quote.text}` : "";
|
|
325
|
+
return {
|
|
326
|
+
title: data.text?.slice(0, 100) || "Tweet",
|
|
327
|
+
author: data.author ? `@${data.handle || data.author}` : void 0,
|
|
328
|
+
content: text + quoteText
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (platform === "spotify") {
|
|
332
|
+
return {
|
|
333
|
+
title: data.title,
|
|
334
|
+
author: data.author,
|
|
335
|
+
content: data.extracted_content || data.description || null
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (platform === "wechat") {
|
|
339
|
+
return {
|
|
340
|
+
title: data.title,
|
|
341
|
+
author: data.author,
|
|
342
|
+
content: data.extracted_content || data.content || null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
title: data.title,
|
|
347
|
+
author: data.author,
|
|
348
|
+
content: data.content || data.extracted_content || data.text || data.transcript || null
|
|
349
|
+
};
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return { error: err.message || "Fetch failed" };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function handleFetchContent(args) {
|
|
355
|
+
const { url } = args;
|
|
356
|
+
const platform = detectPlatform(url);
|
|
357
|
+
const { data: existing } = await supabase.from("bookmarks").select("*").eq("url", url).limit(1).maybeSingle();
|
|
358
|
+
if (existing) {
|
|
359
|
+
const m = existing.content_metadata || {};
|
|
360
|
+
if (m.extracted_content && m.extracted_content.length > 50) {
|
|
361
|
+
return textResult(JSON.stringify({
|
|
362
|
+
source: "cache",
|
|
363
|
+
url,
|
|
364
|
+
platform,
|
|
365
|
+
title: existing.title,
|
|
366
|
+
author: m.author,
|
|
367
|
+
content: m.extracted_content,
|
|
368
|
+
aiPositioning: m.ai_positioning,
|
|
369
|
+
aiTakeaway: m.ai_takeaway,
|
|
370
|
+
tags: m.tags
|
|
371
|
+
}, null, 2));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const result = await fetchViaAPI(url, platform);
|
|
375
|
+
if (result.error) {
|
|
376
|
+
return textResult(JSON.stringify({
|
|
377
|
+
source: "error",
|
|
378
|
+
url,
|
|
379
|
+
platform,
|
|
380
|
+
error: result.error,
|
|
381
|
+
hint: platform === "x" ? "X.com content is fetched via Vercel Edge proxy to bypass GFW" : void 0
|
|
382
|
+
}, null, 2));
|
|
383
|
+
}
|
|
384
|
+
return textResult(JSON.stringify({
|
|
385
|
+
source: "live",
|
|
386
|
+
url,
|
|
387
|
+
platform,
|
|
388
|
+
title: result.title,
|
|
389
|
+
author: result.author,
|
|
390
|
+
content: result.content
|
|
391
|
+
}, null, 2));
|
|
392
|
+
}
|
|
393
|
+
async function handleListSparks(args) {
|
|
394
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "read").order("created_at", { ascending: false }).limit(args.limit || 20);
|
|
395
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
396
|
+
const results = (data || []).map((row) => {
|
|
397
|
+
const s = metaSummary(row);
|
|
398
|
+
const m = row.content_metadata || {};
|
|
399
|
+
return {
|
|
400
|
+
...s,
|
|
401
|
+
sparkInsight: m.spark_insight || null,
|
|
402
|
+
sparkExpiresAt: m.spark_expires_at || null
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
406
|
+
}
|
|
407
|
+
async function handleSearchSparks(args) {
|
|
408
|
+
const { query, limit } = args;
|
|
409
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "read").order("created_at", { ascending: false }).limit(50);
|
|
410
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
411
|
+
const results = (data || []).filter((row) => {
|
|
412
|
+
const m = row.content_metadata || {};
|
|
413
|
+
const searchable = [
|
|
414
|
+
row.title || "",
|
|
415
|
+
...m.tags || [],
|
|
416
|
+
...m.ai_takeaway || [],
|
|
417
|
+
m.ai_positioning || "",
|
|
418
|
+
m.spark_insight || ""
|
|
419
|
+
].join(" ").toLowerCase();
|
|
420
|
+
return searchable.includes(query.toLowerCase());
|
|
421
|
+
}).slice(0, limit || 10).map((row) => {
|
|
422
|
+
const s = metaSummary(row);
|
|
423
|
+
const m = row.content_metadata || {};
|
|
424
|
+
return {
|
|
425
|
+
...s,
|
|
426
|
+
sparkInsight: m.spark_insight || null,
|
|
427
|
+
sparkExpiresAt: m.spark_expires_at || null
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
431
|
+
}
|
|
432
|
+
async function handleListFlame(args) {
|
|
433
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "active").order("created_at", { ascending: false }).limit(args.limit || 20);
|
|
434
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
435
|
+
const now = /* @__PURE__ */ new Date();
|
|
436
|
+
const results = (data || []).filter((row) => {
|
|
437
|
+
if (!row.countdown_expires_at) return true;
|
|
438
|
+
return new Date(row.countdown_expires_at).getTime() > now.getTime();
|
|
439
|
+
}).map(flameSummary);
|
|
440
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
441
|
+
}
|
|
442
|
+
async function handleGetFlameDetail(args) {
|
|
443
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).eq("status", "active").single();
|
|
444
|
+
if (error) {
|
|
445
|
+
return textResult(
|
|
446
|
+
error.code === "PGRST116" ? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.` : `Error: ${error.message}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
const m = data.content_metadata || {};
|
|
450
|
+
const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null;
|
|
451
|
+
const now = /* @__PURE__ */ new Date();
|
|
452
|
+
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
|
|
453
|
+
const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
|
|
454
|
+
const result = {
|
|
455
|
+
...flameSummary(data),
|
|
456
|
+
extractedContent: m.extracted_content || null,
|
|
457
|
+
externalURL: m.external_url || null,
|
|
458
|
+
thumbnail: m.thumbnail || null,
|
|
459
|
+
aiFocus: m.ai_focus || null,
|
|
460
|
+
aiUse: m.ai_use || null,
|
|
461
|
+
aiBuzz: m.ai_buzz || null
|
|
462
|
+
};
|
|
463
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
464
|
+
}
|
|
465
|
+
async function handleListVault(args) {
|
|
466
|
+
let query = supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false }).limit(args.limit || 20);
|
|
467
|
+
const { data, error } = await query;
|
|
468
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
469
|
+
let results = (data || []).map(metaSummary);
|
|
470
|
+
if (args.category) {
|
|
471
|
+
results = results.filter(
|
|
472
|
+
(r) => r.vaultCategory?.toLowerCase() === args.category.toLowerCase()
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
476
|
+
}
|
|
477
|
+
async function handleMoveFlameToSpark(args) {
|
|
478
|
+
const { data, error } = await verifyBookmark(args.id, "active");
|
|
479
|
+
if (error) return textResult(`Error: ${error}`);
|
|
480
|
+
const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString();
|
|
481
|
+
const metaFields = { spark_expires_at: sparkExpiresAt };
|
|
482
|
+
if (args.spark_insight) metaFields.spark_insight = args.spark_insight;
|
|
483
|
+
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
484
|
+
status: "read",
|
|
485
|
+
read_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
486
|
+
});
|
|
487
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
488
|
+
return textResult(JSON.stringify({
|
|
489
|
+
success: true,
|
|
490
|
+
id: args.id,
|
|
491
|
+
title: data.title,
|
|
492
|
+
action: "flame \u2192 spark",
|
|
493
|
+
sparkExpiresAt
|
|
494
|
+
}, null, 2));
|
|
495
|
+
}
|
|
496
|
+
async function handleMoveFlameToAsh(args) {
|
|
497
|
+
const { data, error } = await verifyBookmark(args.id, "active");
|
|
498
|
+
if (error) return textResult(`Error: ${error}`);
|
|
499
|
+
const { error: updateErr } = await supabase.from("bookmarks").update({ status: "ash" }).eq("id", args.id);
|
|
500
|
+
if (updateErr) return textResult(`Error: ${updateErr.message}`);
|
|
501
|
+
return textResult(JSON.stringify({
|
|
502
|
+
success: true,
|
|
503
|
+
id: args.id,
|
|
504
|
+
title: data.title,
|
|
505
|
+
action: "flame \u2192 ash",
|
|
506
|
+
reason: args.reason || null
|
|
507
|
+
}, null, 2));
|
|
508
|
+
}
|
|
509
|
+
async function handleMoveSparkToVault(args) {
|
|
510
|
+
const { data, error } = await verifyBookmark(args.id, "read");
|
|
511
|
+
if (error) return textResult(`Error: ${error}`);
|
|
512
|
+
const metaFields = { vaulted_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
513
|
+
if (args.vault_category) metaFields.vault_category = args.vault_category;
|
|
514
|
+
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
515
|
+
status: "absorbed"
|
|
516
|
+
});
|
|
517
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
518
|
+
return textResult(JSON.stringify({
|
|
519
|
+
success: true,
|
|
520
|
+
id: args.id,
|
|
521
|
+
title: data.title,
|
|
522
|
+
action: "spark \u2192 vault",
|
|
523
|
+
vaultCategory: args.vault_category || null
|
|
524
|
+
}, null, 2));
|
|
525
|
+
}
|
|
526
|
+
async function handleMoveSparkToAsh(args) {
|
|
527
|
+
const { data, error } = await verifyBookmark(args.id, "read");
|
|
528
|
+
if (error) return textResult(`Error: ${error}`);
|
|
529
|
+
const { error: updateErr } = await supabase.from("bookmarks").update({ status: "ash" }).eq("id", args.id);
|
|
530
|
+
if (updateErr) return textResult(`Error: ${updateErr.message}`);
|
|
531
|
+
return textResult(JSON.stringify({
|
|
532
|
+
success: true,
|
|
533
|
+
id: args.id,
|
|
534
|
+
title: data.title,
|
|
535
|
+
action: "spark \u2192 ash"
|
|
536
|
+
}, null, 2));
|
|
537
|
+
}
|
|
538
|
+
async function handleBatchTriageFlame(args) {
|
|
539
|
+
const results = [];
|
|
540
|
+
for (const decision of args.decisions) {
|
|
541
|
+
if (decision.action === "spark") {
|
|
542
|
+
const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight });
|
|
543
|
+
const parsed = JSON.parse(res.content[0].text);
|
|
544
|
+
results.push({ id: decision.id, action: "flame \u2192 spark", success: !!parsed.success, error: parsed.success ? void 0 : res.content[0].text, title: parsed.title });
|
|
545
|
+
} else {
|
|
546
|
+
const res = await handleMoveFlameToAsh({ id: decision.id });
|
|
547
|
+
const parsed = JSON.parse(res.content[0].text);
|
|
548
|
+
results.push({ id: decision.id, action: "flame \u2192 ash", success: !!parsed.success, error: parsed.success ? void 0 : res.content[0].text, title: parsed.title });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
552
|
+
const failed = results.filter((r) => !r.success).length;
|
|
553
|
+
return textResult(JSON.stringify({
|
|
554
|
+
summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
|
|
555
|
+
results
|
|
556
|
+
}, null, 2));
|
|
557
|
+
}
|
|
558
|
+
async function handleWriteBookmarkAnalysis(args) {
|
|
559
|
+
const { data, error } = await verifyBookmark(args.id);
|
|
560
|
+
if (error) return textResult(`Error: ${error}`);
|
|
561
|
+
const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis);
|
|
562
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
563
|
+
const fieldsWritten = Object.keys(args.analysis).filter((k) => args.analysis[k] !== void 0);
|
|
564
|
+
return textResult(JSON.stringify({
|
|
565
|
+
success: true,
|
|
566
|
+
id: args.id,
|
|
567
|
+
title: data.title,
|
|
568
|
+
fieldsWritten
|
|
569
|
+
}, null, 2));
|
|
570
|
+
}
|
|
571
|
+
async function handleCreateCollection(args) {
|
|
572
|
+
const { data: sample } = await supabase.from("bookmarks").select("user_id").limit(1).single();
|
|
573
|
+
if (!sample) return textResult("Error: No bookmarks found \u2014 cannot determine user ID");
|
|
574
|
+
const bookmarkIds = args.bookmark_ids || [];
|
|
575
|
+
if (bookmarkIds.length > 0) {
|
|
576
|
+
const { data: existing } = await supabase.from("bookmarks").select("id").in("id", bookmarkIds);
|
|
577
|
+
const existingIds = new Set((existing || []).map((b) => b.id));
|
|
578
|
+
const missing = bookmarkIds.filter((id) => !existingIds.has(id));
|
|
579
|
+
if (missing.length > 0) {
|
|
580
|
+
return textResult(`Error: Bookmark IDs not found: ${missing.join(", ")}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const { data, error } = await supabase.from("collections").insert({
|
|
584
|
+
user_id: sample.user_id,
|
|
585
|
+
name: args.name,
|
|
586
|
+
bookmark_ids: bookmarkIds,
|
|
587
|
+
is_overview_stale: true
|
|
588
|
+
}).select().single();
|
|
589
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
590
|
+
return textResult(JSON.stringify({
|
|
591
|
+
success: true,
|
|
592
|
+
id: data.id,
|
|
593
|
+
name: data.name,
|
|
594
|
+
articleCount: bookmarkIds.length
|
|
595
|
+
}, null, 2));
|
|
596
|
+
}
|
|
597
|
+
async function handleAddToCollection(args) {
|
|
598
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
|
|
599
|
+
if (error) {
|
|
600
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
601
|
+
}
|
|
602
|
+
const { data: existing } = await supabase.from("bookmarks").select("id").in("id", args.bookmark_ids);
|
|
603
|
+
const existingIds = new Set((existing || []).map((b) => b.id));
|
|
604
|
+
const missing = args.bookmark_ids.filter((id) => !existingIds.has(id));
|
|
605
|
+
if (missing.length > 0) {
|
|
606
|
+
return textResult(`Error: Bookmark IDs not found: ${missing.join(", ")}`);
|
|
607
|
+
}
|
|
608
|
+
const currentIds = new Set(collection.bookmark_ids || []);
|
|
609
|
+
const newIds = args.bookmark_ids.filter((id) => !currentIds.has(id));
|
|
610
|
+
const merged = [...collection.bookmark_ids || [], ...newIds];
|
|
611
|
+
const { error: updateErr } = await supabase.from("collections").update({ bookmark_ids: merged, is_overview_stale: true }).eq("id", args.collection_id);
|
|
612
|
+
if (updateErr) return textResult(`Error: ${updateErr.message}`);
|
|
613
|
+
return textResult(JSON.stringify({
|
|
614
|
+
success: true,
|
|
615
|
+
collectionId: args.collection_id,
|
|
616
|
+
name: collection.name,
|
|
617
|
+
added: newIds.length,
|
|
618
|
+
alreadyPresent: args.bookmark_ids.length - newIds.length,
|
|
619
|
+
totalArticles: merged.length
|
|
620
|
+
}, null, 2));
|
|
621
|
+
}
|
|
622
|
+
async function handleRemoveFromCollection(args) {
|
|
623
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
|
|
624
|
+
if (error) {
|
|
625
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
626
|
+
}
|
|
627
|
+
const removeSet = new Set(args.bookmark_ids);
|
|
628
|
+
const filtered = (collection.bookmark_ids || []).filter((id) => !removeSet.has(id));
|
|
629
|
+
const removed = (collection.bookmark_ids || []).length - filtered.length;
|
|
630
|
+
const { error: updateErr } = await supabase.from("collections").update({ bookmark_ids: filtered, is_overview_stale: true }).eq("id", args.collection_id);
|
|
631
|
+
if (updateErr) return textResult(`Error: ${updateErr.message}`);
|
|
632
|
+
return textResult(JSON.stringify({
|
|
633
|
+
success: true,
|
|
634
|
+
collectionId: args.collection_id,
|
|
635
|
+
name: collection.name,
|
|
636
|
+
removed,
|
|
637
|
+
totalArticles: filtered.length
|
|
638
|
+
}, null, 2));
|
|
639
|
+
}
|
|
640
|
+
async function handleUpdateCollectionOverview(args) {
|
|
641
|
+
const { data: collection, error } = await supabase.from("collections").select("id, name").eq("id", args.collection_id).single();
|
|
642
|
+
if (error) {
|
|
643
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
644
|
+
}
|
|
645
|
+
const { error: updateErr } = await supabase.from("collections").update({ ai_overview: args.overview, is_overview_stale: false }).eq("id", args.collection_id);
|
|
646
|
+
if (updateErr) return textResult(`Error: ${updateErr.message}`);
|
|
647
|
+
return textResult(JSON.stringify({
|
|
648
|
+
success: true,
|
|
649
|
+
collectionId: args.collection_id,
|
|
650
|
+
name: collection.name,
|
|
651
|
+
overviewTheme: args.overview.theme
|
|
652
|
+
}, null, 2));
|
|
653
|
+
}
|
|
654
|
+
function decodeXMLEntities(str) {
|
|
655
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
|
|
656
|
+
}
|
|
657
|
+
function extractXMLValue(block, tag) {
|
|
658
|
+
const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, "i");
|
|
659
|
+
const cdata = block.match(cdataRe);
|
|
660
|
+
if (cdata) return cdata[1].trim();
|
|
661
|
+
if (tag === "link") {
|
|
662
|
+
const href = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*(?:\/>|>)/i);
|
|
663
|
+
if (href) return href[1].trim();
|
|
664
|
+
}
|
|
665
|
+
const normalRe = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
|
|
666
|
+
const normal = block.match(normalRe);
|
|
667
|
+
if (normal) return decodeXMLEntities(normal[1].trim());
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
function parseRSSFeed(xml) {
|
|
671
|
+
const items = [];
|
|
672
|
+
const itemRe = /<(?:item|entry)(?: [^>]*)?>([\s\S]*?)<\/(?:item|entry)>/gi;
|
|
673
|
+
let m;
|
|
674
|
+
while ((m = itemRe.exec(xml)) !== null) {
|
|
675
|
+
const block = m[1];
|
|
676
|
+
const title = extractXMLValue(block, "title") || "Untitled";
|
|
677
|
+
const rawUrl = extractXMLValue(block, "link") || extractXMLValue(block, "id") || "";
|
|
678
|
+
if (!rawUrl.startsWith("http")) continue;
|
|
679
|
+
const url = rawUrl.replace(/^https?:\/\/nitter\.[^/]+/, "https://x.com");
|
|
680
|
+
const pubStr = extractXMLValue(block, "pubDate") || extractXMLValue(block, "published") || extractXMLValue(block, "updated") || "";
|
|
681
|
+
let publishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
682
|
+
try {
|
|
683
|
+
if (pubStr) publishedAt = new Date(pubStr).toISOString();
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
const author = extractXMLValue(block, "author") || extractXMLValue(block, "dc:creator") || "";
|
|
687
|
+
items.push({ url, title, author, publishedAt });
|
|
688
|
+
}
|
|
689
|
+
return items;
|
|
690
|
+
}
|
|
691
|
+
async function fetchRSSFeed(feedUrl) {
|
|
692
|
+
const resp = await fetch(feedUrl, {
|
|
693
|
+
signal: AbortSignal.timeout(12e3),
|
|
694
|
+
headers: {
|
|
695
|
+
// Use browser UA — many RSS hosts (bearblog, Substack) block bot UAs
|
|
696
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
697
|
+
Accept: "application/rss+xml, application/atom+xml, text/xml, */*"
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${feedUrl}`);
|
|
701
|
+
return parseRSSFeed(await resp.text());
|
|
702
|
+
}
|
|
703
|
+
async function getSourceItems(source) {
|
|
704
|
+
const since = source.last_checked_at ? new Date(source.last_checked_at) : /* @__PURE__ */ new Date(0);
|
|
705
|
+
switch (source.source_type) {
|
|
706
|
+
case "x_user": {
|
|
707
|
+
throw new Error(`X/Twitter timeline scraping is unavailable \u2014 public nitter/RSS proxies are offline. To add @${source.handle} tweets, use fetch_content with individual tweet URLs.`);
|
|
708
|
+
}
|
|
709
|
+
case "rss": {
|
|
710
|
+
const items = await fetchRSSFeed(source.handle);
|
|
711
|
+
return items.filter((i) => new Date(i.publishedAt) > since);
|
|
712
|
+
}
|
|
713
|
+
case "youtube": {
|
|
714
|
+
const channelId = source.handle.match(/UC[A-Za-z0-9_-]{21}[AQgw]/)?.[0] || source.handle;
|
|
715
|
+
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
|
|
716
|
+
const items = await fetchRSSFeed(rssUrl);
|
|
717
|
+
return items.filter((i) => new Date(i.publishedAt) > since);
|
|
718
|
+
}
|
|
719
|
+
default:
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function handleAddWatchedSource(args) {
|
|
724
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
725
|
+
if (!user) return textResult("Error: Not authenticated");
|
|
726
|
+
const { data: existing } = await supabase.from("watched_sources").select("id, display_name").eq("user_id", user.id).eq("handle", args.handle).eq("active", true).maybeSingle();
|
|
727
|
+
if (existing) return textResult(`Already watching "${existing.display_name}"`);
|
|
728
|
+
const { data, error } = await supabase.from("watched_sources").insert({
|
|
729
|
+
user_id: user.id,
|
|
730
|
+
source_type: args.source_type,
|
|
731
|
+
handle: args.handle,
|
|
732
|
+
display_name: args.name || args.handle
|
|
733
|
+
// null = never checked; first scrape will fetch whatever the feed currently contains
|
|
734
|
+
}).select().single();
|
|
735
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
736
|
+
return textResult(JSON.stringify({
|
|
737
|
+
success: true,
|
|
738
|
+
id: data.id,
|
|
739
|
+
message: `Now watching "${data.display_name}" (${data.source_type}). Call scrape_watched_sources to fetch new items.`
|
|
740
|
+
}, null, 2));
|
|
741
|
+
}
|
|
742
|
+
async function handleListWatchedSources() {
|
|
743
|
+
const { data, error } = await supabase.from("watched_sources").select("id, source_type, handle, display_name, last_checked_at, created_at").eq("active", true).order("created_at", { ascending: false });
|
|
744
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
745
|
+
if (!data || data.length === 0) return textResult("No watched sources yet. Use add_watched_source to add one.");
|
|
746
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
747
|
+
}
|
|
748
|
+
async function handleRemoveWatchedSource(args) {
|
|
749
|
+
const { error } = await supabase.from("watched_sources").update({ active: false }).eq("id", args.id);
|
|
750
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
751
|
+
return textResult("Watched source removed.");
|
|
752
|
+
}
|
|
753
|
+
async function handleScrapeWatchedSources(args) {
|
|
754
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
755
|
+
if (!user) return textResult("Error: Not authenticated");
|
|
756
|
+
let query = supabase.from("watched_sources").select("*").eq("active", true).eq("user_id", user.id);
|
|
757
|
+
if (args.source_id) query = query.eq("id", args.source_id);
|
|
758
|
+
const { data: sources, error } = await query;
|
|
759
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
760
|
+
if (!sources || sources.length === 0) {
|
|
761
|
+
return textResult("No active watched sources. Use add_watched_source to add one.");
|
|
762
|
+
}
|
|
763
|
+
const countdownExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1e3).toISOString();
|
|
764
|
+
const results = [];
|
|
765
|
+
for (const source of sources) {
|
|
766
|
+
let added = 0;
|
|
767
|
+
let skipped = 0;
|
|
768
|
+
try {
|
|
769
|
+
const items = await getSourceItems(source);
|
|
770
|
+
for (const item of items) {
|
|
771
|
+
const { data: dupe } = await supabase.from("bookmarks").select("id").eq("url", item.url).maybeSingle();
|
|
772
|
+
if (dupe) {
|
|
773
|
+
skipped++;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const { error: insertErr } = await supabase.from("bookmarks").insert({
|
|
777
|
+
user_id: user.id,
|
|
778
|
+
url: item.url,
|
|
779
|
+
title: item.title,
|
|
780
|
+
platform: detectPlatform(item.url),
|
|
781
|
+
status: "active",
|
|
782
|
+
countdown_expires_at: countdownExpiresAt,
|
|
783
|
+
content_metadata: {
|
|
784
|
+
author: item.author || source.display_name,
|
|
785
|
+
watched_source_id: source.id,
|
|
786
|
+
watched_source_name: source.display_name
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
if (!insertErr) added++;
|
|
790
|
+
}
|
|
791
|
+
await supabase.from("watched_sources").update({ last_checked_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", source.id);
|
|
792
|
+
results.push({ source: source.display_name, type: source.source_type, added, skipped });
|
|
793
|
+
} catch (err) {
|
|
794
|
+
results.push({ source: source.display_name, type: source.source_type, error: err.message });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const totalAdded = results.reduce((s, r) => s + (r.added || 0), 0);
|
|
798
|
+
return textResult(JSON.stringify({ totalAdded, sources: results }, null, 2));
|
|
799
|
+
}
|
|
800
|
+
server2.tool(
|
|
801
|
+
"search_vault",
|
|
802
|
+
"Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)",
|
|
803
|
+
{ query: z.string().describe("Search keyword"), limit: z.number().optional().describe("Max results (default 10)") },
|
|
804
|
+
rateLimited(handleSearchVault)
|
|
805
|
+
);
|
|
806
|
+
server2.tool(
|
|
807
|
+
"list_vault",
|
|
808
|
+
"List bookmarks in your Vault, optionally filtered by category",
|
|
809
|
+
{ limit: z.number().optional().describe("Max results (default 20)"), category: z.string().optional().describe("Filter by vault category") },
|
|
810
|
+
rateLimited(handleListVault)
|
|
811
|
+
);
|
|
812
|
+
server2.tool(
|
|
813
|
+
"list_sparks",
|
|
814
|
+
"List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.",
|
|
815
|
+
{ limit: z.number().optional().describe("Max results (default 20)") },
|
|
816
|
+
rateLimited(handleListSparks)
|
|
817
|
+
);
|
|
818
|
+
server2.tool(
|
|
819
|
+
"search_sparks",
|
|
820
|
+
"Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)",
|
|
821
|
+
{ query: z.string().describe("Search keyword"), limit: z.number().optional().describe("Max results (default 10)") },
|
|
822
|
+
rateLimited(handleSearchSparks)
|
|
823
|
+
);
|
|
824
|
+
server2.tool(
|
|
825
|
+
"get_bookmark",
|
|
826
|
+
"Get full details of a single bookmark including AI analysis and extracted content",
|
|
827
|
+
{ id: z.string().describe("Bookmark UUID") },
|
|
828
|
+
rateLimited(handleGetBookmark)
|
|
829
|
+
);
|
|
830
|
+
server2.tool(
|
|
831
|
+
"get_article_content",
|
|
832
|
+
"Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)",
|
|
833
|
+
{ id: z.string().describe("Bookmark UUID") },
|
|
834
|
+
rateLimited(handleGetArticleContent)
|
|
835
|
+
);
|
|
836
|
+
server2.tool(
|
|
837
|
+
"fetch_content",
|
|
838
|
+
"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.",
|
|
839
|
+
{ url: z.string().describe("The URL to fetch content from") },
|
|
840
|
+
rateLimited(handleFetchContent)
|
|
841
|
+
);
|
|
842
|
+
server2.tool(
|
|
843
|
+
"list_categories",
|
|
844
|
+
"List all Vault categories with article counts",
|
|
845
|
+
{},
|
|
846
|
+
rateLimited(handleListCategories)
|
|
847
|
+
);
|
|
848
|
+
server2.tool(
|
|
849
|
+
"list_flame",
|
|
850
|
+
"List bookmarks in your Flame inbox (24h countdown). Shows AI triage info (strategy, relevance, novelty, hook) and time remaining. Use this to see what needs attention before it burns to Ash.",
|
|
851
|
+
{ limit: z.number().optional().describe("Max results (default 20)") },
|
|
852
|
+
rateLimited(handleListFlame)
|
|
853
|
+
);
|
|
854
|
+
server2.tool(
|
|
855
|
+
"get_flame_detail",
|
|
856
|
+
"Get full details of a Flame bookmark including extracted article content, AI analysis, and reading guidance. Use this to deep-read a bookmark before deciding its fate.",
|
|
857
|
+
{ id: z.string().describe("Bookmark UUID") },
|
|
858
|
+
rateLimited(handleGetFlameDetail)
|
|
859
|
+
);
|
|
860
|
+
server2.tool(
|
|
861
|
+
"get_collections",
|
|
862
|
+
"List all your Collections with article counts and AI overview themes",
|
|
863
|
+
{},
|
|
864
|
+
rateLimited(handleGetCollections)
|
|
865
|
+
);
|
|
866
|
+
server2.tool(
|
|
867
|
+
"get_collection_overview",
|
|
868
|
+
"Get a Collection by name with its AI overview and linked bookmarks metadata",
|
|
869
|
+
{ name: z.string().describe("Collection name") },
|
|
870
|
+
rateLimited(handleGetCollectionOverview)
|
|
871
|
+
);
|
|
872
|
+
server2.tool(
|
|
873
|
+
"move_flame_to_spark",
|
|
874
|
+
"Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.",
|
|
875
|
+
{
|
|
876
|
+
id: z.string().describe("Bookmark UUID"),
|
|
877
|
+
spark_insight: z.string().max(500).optional().describe("One-line insight about why this is worth reading")
|
|
878
|
+
},
|
|
879
|
+
rateLimited(handleMoveFlameToSpark)
|
|
880
|
+
);
|
|
881
|
+
server2.tool(
|
|
882
|
+
"move_flame_to_ash",
|
|
883
|
+
"Burn a Flame bookmark to Ash (not worth keeping).",
|
|
884
|
+
{
|
|
885
|
+
id: z.string().describe("Bookmark UUID"),
|
|
886
|
+
reason: z.string().max(200).optional().describe("Why this was burned")
|
|
887
|
+
},
|
|
888
|
+
rateLimited(handleMoveFlameToAsh)
|
|
889
|
+
);
|
|
890
|
+
server2.tool(
|
|
891
|
+
"move_spark_to_vault",
|
|
892
|
+
"Promote a Spark bookmark to permanent Vault storage.",
|
|
893
|
+
{
|
|
894
|
+
id: z.string().describe("Bookmark UUID"),
|
|
895
|
+
vault_category: z.string().max(100).optional().describe("Category to file under in the Vault")
|
|
896
|
+
},
|
|
897
|
+
rateLimited(handleMoveSparkToVault)
|
|
898
|
+
);
|
|
899
|
+
server2.tool(
|
|
900
|
+
"move_spark_to_ash",
|
|
901
|
+
"Burn a Spark bookmark to Ash (not valuable enough to vault).",
|
|
902
|
+
{
|
|
903
|
+
id: z.string().describe("Bookmark UUID")
|
|
904
|
+
},
|
|
905
|
+
rateLimited(handleMoveSparkToAsh)
|
|
906
|
+
);
|
|
907
|
+
server2.tool(
|
|
908
|
+
"batch_triage_flame",
|
|
909
|
+
"Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.",
|
|
910
|
+
{
|
|
911
|
+
decisions: z.array(z.object({
|
|
912
|
+
id: z.string().describe("Bookmark UUID"),
|
|
913
|
+
action: z.enum(["spark", "ash"]).describe("spark = keep, ash = burn"),
|
|
914
|
+
spark_insight: z.string().max(500).optional().describe("Insight (only for spark action)")
|
|
915
|
+
})).min(1).max(20).describe("Array of triage decisions")
|
|
916
|
+
},
|
|
917
|
+
rateLimited(handleBatchTriageFlame)
|
|
918
|
+
);
|
|
919
|
+
server2.tool(
|
|
920
|
+
"write_bookmark_analysis",
|
|
921
|
+
"Write AI analysis results into a bookmark. Agent analyzes content with its own LLM, then writes structured results back to Burn. Only provided fields are merged \u2014 existing data is preserved.",
|
|
922
|
+
{
|
|
923
|
+
id: z.string().describe("Bookmark UUID"),
|
|
924
|
+
analysis: z.object({
|
|
925
|
+
ai_summary: z.string().max(200).optional().describe("One-line summary"),
|
|
926
|
+
ai_strategy: z.enum(["deep_read", "skim", "skip_read", "reference"]).optional().describe("Reading strategy"),
|
|
927
|
+
ai_strategy_reason: z.string().max(200).optional().describe("Why this strategy"),
|
|
928
|
+
ai_minutes: z.number().int().min(1).max(999).optional().describe("Estimated reading minutes"),
|
|
929
|
+
ai_takeaway: z.array(z.string().max(200)).max(5).optional().describe("Key takeaways"),
|
|
930
|
+
ai_relevance: z.number().int().min(0).max(100).optional().describe("Relevance score 0-100"),
|
|
931
|
+
ai_novelty: z.number().int().min(0).max(100).optional().describe("Novelty score 0-100"),
|
|
932
|
+
tags: z.array(z.string().max(50)).max(10).optional().describe("Topic tags")
|
|
933
|
+
}).describe("Analysis fields to write")
|
|
934
|
+
},
|
|
935
|
+
rateLimited(handleWriteBookmarkAnalysis)
|
|
936
|
+
);
|
|
937
|
+
server2.tool(
|
|
938
|
+
"create_collection",
|
|
939
|
+
"Create a new Collection to group related bookmarks together.",
|
|
940
|
+
{
|
|
941
|
+
name: z.string().min(1).max(200).describe("Collection name"),
|
|
942
|
+
bookmark_ids: z.array(z.string()).optional().describe("Initial bookmark UUIDs to include")
|
|
943
|
+
},
|
|
944
|
+
rateLimited(handleCreateCollection)
|
|
945
|
+
);
|
|
946
|
+
server2.tool(
|
|
947
|
+
"add_to_collection",
|
|
948
|
+
"Add bookmarks to an existing Collection. Duplicates are silently ignored.",
|
|
949
|
+
{
|
|
950
|
+
collection_id: z.string().describe("Collection UUID"),
|
|
951
|
+
bookmark_ids: z.array(z.string()).min(1).max(50).describe("Bookmark UUIDs to add")
|
|
952
|
+
},
|
|
953
|
+
rateLimited(handleAddToCollection)
|
|
954
|
+
);
|
|
955
|
+
server2.tool(
|
|
956
|
+
"remove_from_collection",
|
|
957
|
+
"Remove bookmarks from a Collection.",
|
|
958
|
+
{
|
|
959
|
+
collection_id: z.string().describe("Collection UUID"),
|
|
960
|
+
bookmark_ids: z.array(z.string()).min(1).describe("Bookmark UUIDs to remove")
|
|
961
|
+
},
|
|
962
|
+
rateLimited(handleRemoveFromCollection)
|
|
963
|
+
);
|
|
964
|
+
server2.tool(
|
|
965
|
+
"update_collection_overview",
|
|
966
|
+
"Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).",
|
|
967
|
+
{
|
|
968
|
+
collection_id: z.string().describe("Collection UUID"),
|
|
969
|
+
overview: z.object({
|
|
970
|
+
theme: z.string().describe("Overarching theme"),
|
|
971
|
+
synthesis: z.string().optional().describe("Cross-bookmark synthesis"),
|
|
972
|
+
patterns: z.array(z.string()).optional().describe("Patterns identified"),
|
|
973
|
+
gaps: z.array(z.string()).optional().describe("Knowledge gaps identified")
|
|
974
|
+
}).describe("AI-generated overview")
|
|
975
|
+
},
|
|
976
|
+
rateLimited(handleUpdateCollectionOverview)
|
|
977
|
+
);
|
|
978
|
+
server2.tool(
|
|
979
|
+
"add_watched_source",
|
|
980
|
+
"Watch an X user, RSS feed, or YouTube channel \u2014 new posts auto-appear in Burn Flame on each scrape.",
|
|
981
|
+
{
|
|
982
|
+
source_type: z.enum(["x_user", "rss", "youtube"]).describe("x_user = Twitter/X handle | rss = any RSS/Atom feed URL | youtube = YouTube channel ID"),
|
|
983
|
+
handle: z.string().describe('x_user: username without @ (e.g. "karpathy") | rss: full feed URL | youtube: channel ID starting with UC'),
|
|
984
|
+
name: z.string().optional().describe("Human-friendly display name (defaults to handle)")
|
|
985
|
+
},
|
|
986
|
+
rateLimited(handleAddWatchedSource)
|
|
987
|
+
);
|
|
988
|
+
server2.tool(
|
|
989
|
+
"list_watched_sources",
|
|
990
|
+
"List all active watched sources (X users, RSS feeds, YouTube channels).",
|
|
991
|
+
{},
|
|
992
|
+
rateLimited(handleListWatchedSources)
|
|
993
|
+
);
|
|
994
|
+
server2.tool(
|
|
995
|
+
"remove_watched_source",
|
|
996
|
+
"Stop watching a source. Use list_watched_sources to find the source ID.",
|
|
997
|
+
{ id: z.string().describe("Watched source UUID from list_watched_sources") },
|
|
998
|
+
rateLimited(handleRemoveWatchedSource)
|
|
999
|
+
);
|
|
1000
|
+
server2.tool(
|
|
1001
|
+
"scrape_watched_sources",
|
|
1002
|
+
"Fetch new content from all watched sources (or one specific source) and add new items to Burn Flame. Call this on a schedule or on demand.",
|
|
1003
|
+
{ source_id: z.string().optional().describe("Scrape only this source ID \u2014 omit to scrape all active sources") },
|
|
1004
|
+
rateLimited(handleScrapeWatchedSources)
|
|
1005
|
+
);
|
|
1006
|
+
server2.resource(
|
|
1007
|
+
"vault-bookmarks",
|
|
1008
|
+
"burn://vault/bookmarks",
|
|
1009
|
+
async (uri) => {
|
|
1010
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false });
|
|
1011
|
+
if (error) {
|
|
1012
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: error.message }) }] };
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
contents: [{
|
|
1016
|
+
uri: uri.href,
|
|
1017
|
+
mimeType: "application/json",
|
|
1018
|
+
text: JSON.stringify((data || []).map(metaSummary), null, 2)
|
|
1019
|
+
}]
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
);
|
|
1023
|
+
server2.resource(
|
|
1024
|
+
"vault-categories",
|
|
1025
|
+
"burn://vault/categories",
|
|
1026
|
+
async (uri) => {
|
|
1027
|
+
const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("status", "absorbed");
|
|
1028
|
+
if (error) {
|
|
1029
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: error.message }) }] };
|
|
1030
|
+
}
|
|
1031
|
+
const counts = {};
|
|
1032
|
+
for (const row of data || []) {
|
|
1033
|
+
const cat = row.content_metadata?.vault_category || "Uncategorized";
|
|
1034
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
contents: [{
|
|
1038
|
+
uri: uri.href,
|
|
1039
|
+
mimeType: "application/json",
|
|
1040
|
+
text: JSON.stringify(
|
|
1041
|
+
Object.entries(counts).map(([category, count]) => ({ category, count })),
|
|
1042
|
+
null,
|
|
1043
|
+
2
|
|
1044
|
+
)
|
|
1045
|
+
}]
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
);
|
|
1049
|
+
return server2;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/http.ts
|
|
1053
|
+
var SUPABASE_URL = process.env.BURN_SUPABASE_URL || "https://juqtxylquemiuvvmgbej.supabase.co";
|
|
1054
|
+
var SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || "sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO";
|
|
1055
|
+
async function handleMcpRequest(req) {
|
|
1056
|
+
const authHeader = req.headers.get("authorization") || req.headers.get("Authorization") || "";
|
|
1057
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
1058
|
+
const token = match?.[1]?.trim();
|
|
1059
|
+
if (!token) {
|
|
1060
|
+
return new Response(JSON.stringify({
|
|
1061
|
+
error: "Missing Authorization header",
|
|
1062
|
+
hint: "Add `Authorization: Bearer <BURN_MCP_TOKEN>` header. Get your token at https://burn451.cloud \u2192 Settings \u2192 MCP Server."
|
|
1063
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
1064
|
+
}
|
|
1065
|
+
let session;
|
|
1066
|
+
try {
|
|
1067
|
+
session = await getOrExchangeSession(token);
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
return new Response(JSON.stringify({
|
|
1070
|
+
error: "Invalid or expired Burn MCP token",
|
|
1071
|
+
detail: String(e?.message || e)
|
|
1072
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
1073
|
+
}
|
|
1074
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
1075
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
1076
|
+
});
|
|
1077
|
+
await applySession(supabase, session);
|
|
1078
|
+
const server2 = createBurnServer(supabase, { rateLimitPerMin: 60 });
|
|
1079
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1080
|
+
sessionIdGenerator: void 0,
|
|
1081
|
+
// stateless mode — MCP SDK treats each request as a new conversation
|
|
1082
|
+
enableJsonResponse: true
|
|
1083
|
+
});
|
|
1084
|
+
await server2.connect(transport);
|
|
1085
|
+
return transport.handleRequest(req);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/http-dev.ts
|
|
1089
|
+
var PORT = Number(process.env.PORT) || 3001;
|
|
1090
|
+
var server = createServer(async (nodeReq, nodeRes) => {
|
|
1091
|
+
try {
|
|
1092
|
+
const url = new URL(nodeReq.url || "/", `http://${nodeReq.headers.host || "localhost"}`);
|
|
1093
|
+
const headers = new Headers();
|
|
1094
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
|
1095
|
+
if (typeof v === "string") headers.set(k, v);
|
|
1096
|
+
}
|
|
1097
|
+
const body = ["GET", "HEAD"].includes(nodeReq.method || "GET") ? null : await new Promise((resolve, reject) => {
|
|
1098
|
+
const chunks = [];
|
|
1099
|
+
nodeReq.on("data", (c) => chunks.push(c));
|
|
1100
|
+
nodeReq.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1101
|
+
nodeReq.on("error", reject);
|
|
1102
|
+
});
|
|
1103
|
+
const webReq = new Request(url, {
|
|
1104
|
+
method: nodeReq.method,
|
|
1105
|
+
headers,
|
|
1106
|
+
body: body && body.length > 0 ? body : null
|
|
1107
|
+
});
|
|
1108
|
+
const webRes = await handleMcpRequest(webReq);
|
|
1109
|
+
nodeRes.statusCode = webRes.status;
|
|
1110
|
+
webRes.headers.forEach((v, k) => nodeRes.setHeader(k, v));
|
|
1111
|
+
if (webRes.body) {
|
|
1112
|
+
const reader = webRes.body.getReader();
|
|
1113
|
+
while (true) {
|
|
1114
|
+
const { done, value } = await reader.read();
|
|
1115
|
+
if (done) break;
|
|
1116
|
+
nodeRes.write(value);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
nodeRes.end();
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
nodeRes.statusCode = 500;
|
|
1122
|
+
nodeRes.setHeader("content-type", "application/json");
|
|
1123
|
+
nodeRes.end(JSON.stringify({ error: "Internal error", detail: String(e?.message || e) }));
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
server.listen(PORT, () => {
|
|
1127
|
+
console.log(`Burn MCP HTTP server listening on http://localhost:${PORT}`);
|
|
1128
|
+
console.log(`Test: curl -H "Authorization: Bearer <TOKEN>" -X POST http://localhost:${PORT}/mcp \\`);
|
|
1129
|
+
console.log(` -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\`);
|
|
1130
|
+
console.log(` -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'`);
|
|
1131
|
+
});
|