burn-mcp-server 1.2.0 → 1.4.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 +114 -24
- package/package.json +1 -1
- package/src/index.ts +122 -24
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_
|
|
|
13
13
|
// Support both old JWT token (BURN_SUPABASE_TOKEN) and new long-lived MCP token (BURN_MCP_TOKEN)
|
|
14
14
|
const MCP_TOKEN = process.env.BURN_MCP_TOKEN;
|
|
15
15
|
const LEGACY_JWT = process.env.BURN_SUPABASE_TOKEN;
|
|
16
|
+
const EXCHANGE_URL = process.env.BURN_MCP_EXCHANGE_URL || 'https://api.burn451.cloud/api/mcp-exchange';
|
|
16
17
|
if (!MCP_TOKEN && !LEGACY_JWT) {
|
|
17
18
|
console.error('Error: BURN_MCP_TOKEN environment variable is required.');
|
|
18
19
|
console.error('Get your token from: Burn App → Settings → MCP Server → Generate Token');
|
|
@@ -30,31 +31,111 @@ const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY
|
|
|
30
31
|
});
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
// Auth: exchange MCP token for a real Supabase session (auto-refreshes)
|
|
34
|
+
// Caches session locally so exchange is only needed on first run or token expiry
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
36
|
+
const node_os_1 = require("node:os");
|
|
37
|
+
const node_fs_1 = require("node:fs");
|
|
38
|
+
const node_path_1 = require("node:path");
|
|
39
|
+
const SESSION_CACHE_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.burn');
|
|
40
|
+
const SESSION_CACHE_FILE = (0, node_path_1.join)(SESSION_CACHE_DIR, 'mcp-session.json');
|
|
41
|
+
function loadCachedSession() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = (0, node_fs_1.readFileSync)(SESSION_CACHE_FILE, 'utf-8');
|
|
44
|
+
const data = JSON.parse(raw);
|
|
45
|
+
if (data.access_token && data.refresh_token)
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
catch { /* no cache or invalid */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function saveCachedSession(access_token, refresh_token) {
|
|
52
|
+
try {
|
|
53
|
+
(0, node_fs_1.mkdirSync)(SESSION_CACHE_DIR, { recursive: true });
|
|
54
|
+
(0, node_fs_1.writeFileSync)(SESSION_CACHE_FILE, JSON.stringify({ access_token, refresh_token }), { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
catch { /* non-fatal — next startup will re-exchange */ }
|
|
57
|
+
}
|
|
34
58
|
async function initAuth() {
|
|
35
59
|
if (LEGACY_JWT)
|
|
36
60
|
return; // legacy mode: JWT already set in headers above
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
// Step 1: Try cached session (avoids network call on every restart)
|
|
62
|
+
const cached = loadCachedSession();
|
|
63
|
+
if (cached) {
|
|
64
|
+
const { error } = await supabase.auth.setSession(cached);
|
|
65
|
+
if (!error) {
|
|
66
|
+
// Listen for token refresh so we keep the cache fresh
|
|
67
|
+
supabase.auth.onAuthStateChange((_event, session) => {
|
|
68
|
+
if (session?.access_token && session?.refresh_token) {
|
|
69
|
+
saveCachedSession(session.access_token, session.refresh_token);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
console.error('Burn MCP: restored session from cache (no network needed)');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.error('Burn MCP: cached session expired, re-exchanging...');
|
|
76
|
+
}
|
|
77
|
+
// Step 2: Exchange MCP token for a fresh Supabase session via Vercel API
|
|
78
|
+
try {
|
|
79
|
+
const resp = await fetch(EXCHANGE_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ token: MCP_TOKEN }),
|
|
83
|
+
});
|
|
84
|
+
if (!resp.ok) {
|
|
85
|
+
const body = await resp.json().catch(() => ({}));
|
|
86
|
+
console.error(`Error: Token exchange failed (${resp.status}): ${body.error || 'Unknown'}`);
|
|
87
|
+
console.error('Tokens expire after 30 days. Generate a new one in Burn App → Settings → MCP Server.');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const { access_token, refresh_token } = await resp.json();
|
|
91
|
+
const { error: sessionError } = await supabase.auth.setSession({
|
|
92
|
+
access_token,
|
|
93
|
+
refresh_token,
|
|
94
|
+
});
|
|
95
|
+
if (sessionError) {
|
|
96
|
+
console.error('Error: Failed to set session.', sessionError.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
// Cache for next startup + listen for refresh
|
|
100
|
+
saveCachedSession(access_token, refresh_token);
|
|
101
|
+
supabase.auth.onAuthStateChange((_event, session) => {
|
|
102
|
+
if (session?.access_token && session?.refresh_token) {
|
|
103
|
+
saveCachedSession(session.access_token, session.refresh_token);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
console.error('Burn MCP: session exchanged and cached');
|
|
43
107
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.error('Error: Failed to refresh session with stored token.', refreshError.message);
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.error('Error: Could not reach token exchange endpoint.', err.message);
|
|
110
|
+
console.error(`URL: ${EXCHANGE_URL}`);
|
|
48
111
|
process.exit(1);
|
|
49
112
|
}
|
|
50
|
-
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Rate limiter — simple sliding window (per MCP session, in-memory)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
|
|
118
|
+
const RATE_LIMIT_MAX_CALLS = 30; // max tool calls per window
|
|
119
|
+
const rateLimitLog = [];
|
|
120
|
+
function checkRateLimit() {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
// Remove entries outside the window
|
|
123
|
+
while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
124
|
+
rateLimitLog.shift();
|
|
125
|
+
}
|
|
126
|
+
if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
|
|
127
|
+
const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1000);
|
|
128
|
+
return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`;
|
|
129
|
+
}
|
|
130
|
+
rateLimitLog.push(now);
|
|
131
|
+
return null;
|
|
51
132
|
}
|
|
52
133
|
// ---------------------------------------------------------------------------
|
|
53
134
|
// MCP Server
|
|
54
135
|
// ---------------------------------------------------------------------------
|
|
55
136
|
const server = new mcp_js_1.McpServer({
|
|
56
137
|
name: 'burn-mcp-server',
|
|
57
|
-
version: '1.
|
|
138
|
+
version: '1.3.0',
|
|
58
139
|
});
|
|
59
140
|
// ---------------------------------------------------------------------------
|
|
60
141
|
// Helper: standard text result
|
|
@@ -114,8 +195,17 @@ function metaSummary(row) {
|
|
|
114
195
|
};
|
|
115
196
|
}
|
|
116
197
|
// ---------------------------------------------------------------------------
|
|
117
|
-
// Tool handlers
|
|
198
|
+
// Tool handlers (all wrapped with rate limiting)
|
|
118
199
|
// ---------------------------------------------------------------------------
|
|
200
|
+
/** Wrap a handler with rate limiting */
|
|
201
|
+
function rateLimited(handler) {
|
|
202
|
+
return async (args) => {
|
|
203
|
+
const err = checkRateLimit();
|
|
204
|
+
if (err)
|
|
205
|
+
return textResult(err);
|
|
206
|
+
return handler(args);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
119
209
|
async function handleSearchVault(args) {
|
|
120
210
|
const { query, limit } = args;
|
|
121
211
|
const { data, error } = await supabase
|
|
@@ -240,7 +330,7 @@ async function handleGetArticleContent(args) {
|
|
|
240
330
|
// Vercel API base URL for content fetching
|
|
241
331
|
// ---------------------------------------------------------------------------
|
|
242
332
|
const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud';
|
|
243
|
-
const API_KEY = 'burn451-2026-secret-key';
|
|
333
|
+
const API_KEY = process.env.BURN_API_KEY || 'burn451-2026-secret-key';
|
|
244
334
|
/** Detect platform from URL */
|
|
245
335
|
function detectPlatform(url) {
|
|
246
336
|
if (/x\.com|twitter\.com/i.test(url))
|
|
@@ -459,16 +549,16 @@ async function handleListVault(args) {
|
|
|
459
549
|
// Register tools
|
|
460
550
|
// ---------------------------------------------------------------------------
|
|
461
551
|
// @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
|
|
462
|
-
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);
|
|
463
|
-
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);
|
|
464
|
-
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);
|
|
465
|
-
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);
|
|
466
|
-
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);
|
|
467
|
-
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);
|
|
468
|
-
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);
|
|
469
|
-
server.tool('list_categories', 'List all Vault categories with article counts', {}, handleListCategories);
|
|
470
|
-
server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, handleGetCollections);
|
|
471
|
-
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);
|
|
552
|
+
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)') }, rateLimited(handleSearchVault));
|
|
553
|
+
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') }, rateLimited(handleListVault));
|
|
554
|
+
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)') }, rateLimited(handleListSparks));
|
|
555
|
+
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)') }, rateLimited(handleSearchSparks));
|
|
556
|
+
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') }, rateLimited(handleGetBookmark));
|
|
557
|
+
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') }, rateLimited(handleGetArticleContent));
|
|
558
|
+
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') }, rateLimited(handleFetchContent));
|
|
559
|
+
server.tool('list_categories', 'List all Vault categories with article counts', {}, rateLimited(handleListCategories));
|
|
560
|
+
server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, rateLimited(handleGetCollections));
|
|
561
|
+
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') }, rateLimited(handleGetCollectionOverview));
|
|
472
562
|
// ---------------------------------------------------------------------------
|
|
473
563
|
// Resource: burn://vault/bookmarks
|
|
474
564
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_
|
|
|
15
15
|
// Support both old JWT token (BURN_SUPABASE_TOKEN) and new long-lived MCP token (BURN_MCP_TOKEN)
|
|
16
16
|
const MCP_TOKEN = process.env.BURN_MCP_TOKEN
|
|
17
17
|
const LEGACY_JWT = process.env.BURN_SUPABASE_TOKEN
|
|
18
|
+
const EXCHANGE_URL = process.env.BURN_MCP_EXCHANGE_URL || 'https://api.burn451.cloud/api/mcp-exchange'
|
|
18
19
|
|
|
19
20
|
if (!MCP_TOKEN && !LEGACY_JWT) {
|
|
20
21
|
console.error('Error: BURN_MCP_TOKEN environment variable is required.')
|
|
@@ -36,26 +37,114 @@ const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
|
36
37
|
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
// Auth: exchange MCP token for a real Supabase session (auto-refreshes)
|
|
40
|
+
// Caches session locally so exchange is only needed on first run or token expiry
|
|
39
41
|
// ---------------------------------------------------------------------------
|
|
40
42
|
|
|
43
|
+
import { homedir } from 'node:os'
|
|
44
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
45
|
+
import { join } from 'node:path'
|
|
46
|
+
|
|
47
|
+
const SESSION_CACHE_DIR = join(homedir(), '.burn')
|
|
48
|
+
const SESSION_CACHE_FILE = join(SESSION_CACHE_DIR, 'mcp-session.json')
|
|
49
|
+
|
|
50
|
+
function loadCachedSession(): { access_token: string; refresh_token: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const raw = readFileSync(SESSION_CACHE_FILE, 'utf-8')
|
|
53
|
+
const data = JSON.parse(raw)
|
|
54
|
+
if (data.access_token && data.refresh_token) return data
|
|
55
|
+
} catch { /* no cache or invalid */ }
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function saveCachedSession(access_token: string, refresh_token: string): void {
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(SESSION_CACHE_DIR, { recursive: true })
|
|
62
|
+
writeFileSync(SESSION_CACHE_FILE, JSON.stringify({ access_token, refresh_token }), { mode: 0o600 })
|
|
63
|
+
} catch { /* non-fatal — next startup will re-exchange */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
async function initAuth(): Promise<void> {
|
|
42
67
|
if (LEGACY_JWT) return // legacy mode: JWT already set in headers above
|
|
43
68
|
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
// Step 1: Try cached session (avoids network call on every restart)
|
|
70
|
+
const cached = loadCachedSession()
|
|
71
|
+
if (cached) {
|
|
72
|
+
const { error } = await supabase.auth.setSession(cached)
|
|
73
|
+
if (!error) {
|
|
74
|
+
// Listen for token refresh so we keep the cache fresh
|
|
75
|
+
supabase.auth.onAuthStateChange((_event, session) => {
|
|
76
|
+
if (session?.access_token && session?.refresh_token) {
|
|
77
|
+
saveCachedSession(session.access_token, session.refresh_token)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
console.error('Burn MCP: restored session from cache (no network needed)')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
console.error('Burn MCP: cached session expired, re-exchanging...')
|
|
50
84
|
}
|
|
51
85
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
// Step 2: Exchange MCP token for a fresh Supabase session via Vercel API
|
|
87
|
+
try {
|
|
88
|
+
const resp = await fetch(EXCHANGE_URL, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ token: MCP_TOKEN }),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (!resp.ok) {
|
|
95
|
+
const body = await resp.json().catch(() => ({})) as any
|
|
96
|
+
console.error(`Error: Token exchange failed (${resp.status}): ${body.error || 'Unknown'}`)
|
|
97
|
+
console.error('Tokens expire after 30 days. Generate a new one in Burn App → Settings → MCP Server.')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { access_token, refresh_token } = await resp.json() as any
|
|
102
|
+
|
|
103
|
+
const { error: sessionError } = await supabase.auth.setSession({
|
|
104
|
+
access_token,
|
|
105
|
+
refresh_token,
|
|
106
|
+
})
|
|
107
|
+
if (sessionError) {
|
|
108
|
+
console.error('Error: Failed to set session.', sessionError.message)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Cache for next startup + listen for refresh
|
|
113
|
+
saveCachedSession(access_token, refresh_token)
|
|
114
|
+
supabase.auth.onAuthStateChange((_event, session) => {
|
|
115
|
+
if (session?.access_token && session?.refresh_token) {
|
|
116
|
+
saveCachedSession(session.access_token, session.refresh_token)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
console.error('Burn MCP: session exchanged and cached')
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
console.error('Error: Could not reach token exchange endpoint.', err.message)
|
|
122
|
+
console.error(`URL: ${EXCHANGE_URL}`)
|
|
56
123
|
process.exit(1)
|
|
57
124
|
}
|
|
58
|
-
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Rate limiter — simple sliding window (per MCP session, in-memory)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
|
|
132
|
+
const RATE_LIMIT_MAX_CALLS = 30 // max tool calls per window
|
|
133
|
+
|
|
134
|
+
const rateLimitLog: number[] = []
|
|
135
|
+
|
|
136
|
+
function checkRateLimit(): string | null {
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
// Remove entries outside the window
|
|
139
|
+
while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
140
|
+
rateLimitLog.shift()
|
|
141
|
+
}
|
|
142
|
+
if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
|
|
143
|
+
const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1000)
|
|
144
|
+
return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`
|
|
145
|
+
}
|
|
146
|
+
rateLimitLog.push(now)
|
|
147
|
+
return null
|
|
59
148
|
}
|
|
60
149
|
|
|
61
150
|
// ---------------------------------------------------------------------------
|
|
@@ -64,7 +153,7 @@ async function initAuth(): Promise<void> {
|
|
|
64
153
|
|
|
65
154
|
const server = new McpServer({
|
|
66
155
|
name: 'burn-mcp-server',
|
|
67
|
-
version: '1.
|
|
156
|
+
version: '1.3.0',
|
|
68
157
|
})
|
|
69
158
|
|
|
70
159
|
// ---------------------------------------------------------------------------
|
|
@@ -130,9 +219,18 @@ function metaSummary(row: any): any {
|
|
|
130
219
|
}
|
|
131
220
|
|
|
132
221
|
// ---------------------------------------------------------------------------
|
|
133
|
-
// Tool handlers
|
|
222
|
+
// Tool handlers (all wrapped with rate limiting)
|
|
134
223
|
// ---------------------------------------------------------------------------
|
|
135
224
|
|
|
225
|
+
/** Wrap a handler with rate limiting */
|
|
226
|
+
function rateLimited<T>(handler: (args: T) => Promise<{ content: { type: 'text'; text: string }[] }>) {
|
|
227
|
+
return async (args: T) => {
|
|
228
|
+
const err = checkRateLimit()
|
|
229
|
+
if (err) return textResult(err)
|
|
230
|
+
return handler(args)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
136
234
|
async function handleSearchVault(args: { query: string; limit?: number }) {
|
|
137
235
|
const { query, limit } = args
|
|
138
236
|
const { data, error } = await supabase
|
|
@@ -279,7 +377,7 @@ async function handleGetArticleContent(args: { id: string }) {
|
|
|
279
377
|
// ---------------------------------------------------------------------------
|
|
280
378
|
|
|
281
379
|
const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud'
|
|
282
|
-
const API_KEY = 'burn451-2026-secret-key'
|
|
380
|
+
const API_KEY = process.env.BURN_API_KEY || 'burn451-2026-secret-key'
|
|
283
381
|
|
|
284
382
|
/** Detect platform from URL */
|
|
285
383
|
function detectPlatform(url: string): string {
|
|
@@ -525,70 +623,70 @@ server.tool(
|
|
|
525
623
|
'search_vault',
|
|
526
624
|
'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)',
|
|
527
625
|
{ query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
|
|
528
|
-
handleSearchVault
|
|
626
|
+
rateLimited(handleSearchVault)
|
|
529
627
|
)
|
|
530
628
|
|
|
531
629
|
server.tool(
|
|
532
630
|
'list_vault',
|
|
533
631
|
'List bookmarks in your Vault, optionally filtered by category',
|
|
534
632
|
{ limit: z.number().optional().describe('Max results (default 20)'), category: z.string().optional().describe('Filter by vault category') },
|
|
535
|
-
handleListVault
|
|
633
|
+
rateLimited(handleListVault)
|
|
536
634
|
)
|
|
537
635
|
|
|
538
636
|
server.tool(
|
|
539
637
|
'list_sparks',
|
|
540
638
|
'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.',
|
|
541
639
|
{ limit: z.number().optional().describe('Max results (default 20)') },
|
|
542
|
-
handleListSparks
|
|
640
|
+
rateLimited(handleListSparks)
|
|
543
641
|
)
|
|
544
642
|
|
|
545
643
|
server.tool(
|
|
546
644
|
'search_sparks',
|
|
547
645
|
'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)',
|
|
548
646
|
{ query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Max results (default 10)') },
|
|
549
|
-
handleSearchSparks
|
|
647
|
+
rateLimited(handleSearchSparks)
|
|
550
648
|
)
|
|
551
649
|
|
|
552
650
|
server.tool(
|
|
553
651
|
'get_bookmark',
|
|
554
652
|
'Get full details of a single bookmark including AI analysis and extracted content',
|
|
555
653
|
{ id: z.string().describe('Bookmark UUID') },
|
|
556
|
-
handleGetBookmark
|
|
654
|
+
rateLimited(handleGetBookmark)
|
|
557
655
|
)
|
|
558
656
|
|
|
559
657
|
server.tool(
|
|
560
658
|
'get_article_content',
|
|
561
659
|
'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)',
|
|
562
660
|
{ id: z.string().describe('Bookmark UUID') },
|
|
563
|
-
handleGetArticleContent
|
|
661
|
+
rateLimited(handleGetArticleContent)
|
|
564
662
|
)
|
|
565
663
|
|
|
566
664
|
server.tool(
|
|
567
665
|
'fetch_content',
|
|
568
666
|
'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.',
|
|
569
667
|
{ url: z.string().describe('The URL to fetch content from') },
|
|
570
|
-
handleFetchContent
|
|
668
|
+
rateLimited(handleFetchContent)
|
|
571
669
|
)
|
|
572
670
|
|
|
573
671
|
server.tool(
|
|
574
672
|
'list_categories',
|
|
575
673
|
'List all Vault categories with article counts',
|
|
576
674
|
{},
|
|
577
|
-
handleListCategories
|
|
675
|
+
rateLimited(handleListCategories)
|
|
578
676
|
)
|
|
579
677
|
|
|
580
678
|
server.tool(
|
|
581
679
|
'get_collections',
|
|
582
680
|
'List all your Collections with article counts and AI overview themes',
|
|
583
681
|
{},
|
|
584
|
-
handleGetCollections
|
|
682
|
+
rateLimited(handleGetCollections)
|
|
585
683
|
)
|
|
586
684
|
|
|
587
685
|
server.tool(
|
|
588
686
|
'get_collection_overview',
|
|
589
687
|
'Get a Collection by name with its AI overview and linked bookmarks metadata',
|
|
590
688
|
{ name: z.string().describe('Collection name') },
|
|
591
|
-
handleGetCollectionOverview
|
|
689
|
+
rateLimited(handleGetCollectionOverview)
|
|
592
690
|
)
|
|
593
691
|
|
|
594
692
|
// ---------------------------------------------------------------------------
|