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.
Files changed (3) hide show
  1. package/dist/index.js +114 -24
  2. package/package.json +1 -1
  3. 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
- // Call SECURITY DEFINER function works with anon key, no JWT needed
38
- const { data, error } = await supabase.rpc('get_mcp_session', { p_token: MCP_TOKEN });
39
- if (error || !data || data.length === 0) {
40
- console.error('Error: Invalid or revoked BURN_MCP_TOKEN.');
41
- console.error('Generate a new token in Burn App → Settings → MCP Server → Generate Token');
42
- process.exit(1);
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
- const { refresh_token } = data[0];
45
- const { error: refreshError } = await supabase.auth.refreshSession({ refresh_token });
46
- if (refreshError) {
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
- // supabase client now has a valid session and will auto-refresh going forward
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.2.0',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burn-mcp-server",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP Server for Burn — access your Vault from Claude/Cursor",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -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
- // Call SECURITY DEFINER function works with anon key, no JWT needed
45
- const { data, error } = await supabase.rpc('get_mcp_session', { p_token: MCP_TOKEN })
46
- if (error || !data || data.length === 0) {
47
- console.error('Error: Invalid or revoked BURN_MCP_TOKEN.')
48
- console.error('Generate a new token in Burn App → Settings → MCP Server → Generate Token')
49
- process.exit(1)
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
- const { refresh_token } = data[0]
53
- const { error: refreshError } = await supabase.auth.refreshSession({ refresh_token })
54
- if (refreshError) {
55
- console.error('Error: Failed to refresh session with stored token.', refreshError.message)
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
- // supabase client now has a valid session and will auto-refresh going forward
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.2.0',
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
  // ---------------------------------------------------------------------------