burn-mcp-server 1.4.0 → 2.0.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Burn MCP Server
2
2
 
3
- Let Claude Desktop, Cursor, or any MCP-compatible AI tool access your Burn Vault.
3
+ Let Claude Desktop, Cursor, or any MCP-compatible AI tool access and manage your Burn bookmarks.
4
4
 
5
5
  ## Setup
6
6
 
@@ -19,7 +19,7 @@ Add to your `~/.config/claude/claude_desktop_config.json`:
19
19
  "command": "npx",
20
20
  "args": ["burn-mcp-server"],
21
21
  "env": {
22
- "BURN_SUPABASE_TOKEN": "<paste-your-token-here>"
22
+ "BURN_MCP_TOKEN": "<paste-your-token-here>"
23
23
  }
24
24
  }
25
25
  }
@@ -32,13 +32,47 @@ The Burn tools will appear in the tools menu.
32
32
 
33
33
  ## Available Tools
34
34
 
35
+ ### Read Tools
36
+
35
37
  | Tool | Description |
36
38
  |------|-------------|
37
39
  | `search_vault` | Search Vault bookmarks by keyword |
40
+ | `list_vault` | List Vault bookmarks, optionally by category |
41
+ | `list_sparks` | List Sparks (read bookmarks with 30-day lifespan) |
42
+ | `search_sparks` | Search Sparks by keyword |
43
+ | `list_flame` | List Flame inbox (24h countdown, AI triage info) |
44
+ | `get_flame_detail` | Get full Flame bookmark detail with content |
38
45
  | `get_bookmark` | Get full details of a single bookmark |
46
+ | `get_article_content` | Get article content and AI analysis by ID |
47
+ | `fetch_content` | Fetch content from a URL (X, Reddit, YouTube, WeChat, etc.) |
39
48
  | `list_categories` | List all Vault categories with counts |
40
- | `get_clusters` | Get AI-generated topic clusters |
41
- | `get_cluster_digest` | Get cluster digest (summary + relationships) |
49
+ | `get_collections` | List all Collections |
50
+ | `get_collection_overview` | Get a Collection with AI overview and bookmarks |
51
+
52
+ ### Write Tools — Layer 1: Status Flow (决策层)
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `move_flame_to_spark` | Flame → Spark (worth reading). Sets 30-day lifespan. Optional `spark_insight`. |
57
+ | `move_flame_to_ash` | Flame → Ash (burn it). Optional `reason`. |
58
+ | `move_spark_to_vault` | Spark → Vault (permanent). Optional `vault_category`. |
59
+ | `move_spark_to_ash` | Spark → Ash (not valuable enough to vault). |
60
+ | `batch_triage_flame` | Triage up to 20 Flame bookmarks at once (spark or ash). |
61
+
62
+ ### Write Tools — Layer 2: Collections (组合层)
63
+
64
+ | Tool | Description |
65
+ |------|-------------|
66
+ | `create_collection` | Create a new Collection, optionally with initial bookmarks. |
67
+ | `add_to_collection` | Add bookmarks to a Collection (deduplicates). |
68
+ | `remove_from_collection` | Remove bookmarks from a Collection. |
69
+ | `update_collection_overview` | Write AI overview (theme, synthesis, patterns, gaps). |
70
+
71
+ ### Write Tools — Layer 3: AI Analysis (分析层)
72
+
73
+ | Tool | Description |
74
+ |------|-------------|
75
+ | `write_bookmark_analysis` | Write AI analysis back to a bookmark. Agent uses its own LLM to analyze, then writes structured results (summary, strategy, takeaways, relevance, novelty, tags) into Burn. |
42
76
 
43
77
  ## Available Resources
44
78
 
@@ -47,22 +81,38 @@ The Burn tools will appear in the tools menu.
47
81
  | `burn://vault/bookmarks` | All Vault bookmarks (JSON) |
48
82
  | `burn://vault/categories` | Category list (JSON) |
49
83
 
50
- ## Example prompts
84
+ ## Example Prompts
51
85
 
86
+ **Reading:**
52
87
  - "What did I save about SwiftUI animations?"
88
+ - "Show me my Flame inbox — what's about to burn?"
53
89
  - "Summarize my AI-related bookmarks"
54
- - "Reference my Vault article about API design patterns"
55
- - "What are the main topics in my knowledge base?"
90
+
91
+ **Triage (Agent as your filter):**
92
+ - "Review my Flame bookmarks and decide what to keep vs burn"
93
+ - "Triage everything in Flame — skim the content and make decisions"
94
+
95
+ **Analysis (Agent as your analyst):**
96
+ - "Analyze this bookmark and write your assessment back to Burn"
97
+ - "Go through my Sparks and tag them all"
98
+
99
+ **Collections (Agent as your curator):**
100
+ - "Group my Vault bookmarks about AI into a collection"
101
+ - "Write an overview for my 'System Design' collection"
56
102
 
57
103
  ## Environment Variables
58
104
 
59
105
  | Variable | Required | Description |
60
106
  |----------|----------|-------------|
61
- | `BURN_SUPABASE_TOKEN` | Yes | Your Burn access token (JWT) |
62
- | `BURN_SUPABASE_URL` | No | Custom Supabase URL (default: production) |
107
+ | `BURN_MCP_TOKEN` | Yes* | Long-lived MCP token (recommended) |
108
+ | `BURN_SUPABASE_TOKEN` | Yes* | Legacy JWT token (still supported) |
109
+ | `BURN_API_URL` | No | Custom API URL (default: production) |
110
+
111
+ *One of `BURN_MCP_TOKEN` or `BURN_SUPABASE_TOKEN` is required.
63
112
 
64
113
  ## Security
65
114
 
66
- - Your token only grants access to **your own** Vault (enforced by Row Level Security)
67
- - The MCP Server is **read-only** it cannot modify your bookmarks
68
- - Tokens expire; regenerate from the Burn App if needed
115
+ - Your token only grants access to **your own** data (enforced by Row Level Security)
116
+ - Write operations are scoped: status can only flow forward (Flame Spark → Vault, or → Ash)
117
+ - Rate limited: 30 calls/minute per session
118
+ - Tokens expire after 30 days; regenerate from Burn App → Settings → MCP Server
package/dist/index.js CHANGED
@@ -135,7 +135,7 @@ function checkRateLimit() {
135
135
  // ---------------------------------------------------------------------------
136
136
  const server = new mcp_js_1.McpServer({
137
137
  name: 'burn-mcp-server',
138
- version: '1.3.0',
138
+ version: '2.0.0',
139
139
  });
140
140
  // ---------------------------------------------------------------------------
141
141
  // Helper: standard text result
@@ -144,6 +144,51 @@ function textResult(text) {
144
144
  return { content: [{ type: 'text', text }] };
145
145
  }
146
146
  // ---------------------------------------------------------------------------
147
+ // Helper: verify bookmark exists and has expected status
148
+ // ---------------------------------------------------------------------------
149
+ async function verifyBookmark(id, expectedStatus) {
150
+ const { data, error } = await supabase
151
+ .from('bookmarks')
152
+ .select('*')
153
+ .eq('id', id)
154
+ .single();
155
+ if (error)
156
+ return { data: null, error: error.code === 'PGRST116' ? 'Bookmark not found' : error.message };
157
+ if (expectedStatus) {
158
+ const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
159
+ if (!allowed.includes(data.status)) {
160
+ const statusLabels = { active: 'Flame', read: 'Spark', absorbed: 'Vault', ash: 'Ash' };
161
+ return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map(s => statusLabels[s] || s).join(' or ')})` };
162
+ }
163
+ }
164
+ return { data, error: null };
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Helper: merge fields into content_metadata JSONB without overwriting
168
+ // ---------------------------------------------------------------------------
169
+ async function mergeContentMetadata(bookmarkId, fields, extraColumns) {
170
+ const { data, error } = await supabase
171
+ .from('bookmarks')
172
+ .select('content_metadata')
173
+ .eq('id', bookmarkId)
174
+ .single();
175
+ if (error)
176
+ return { error: error.message };
177
+ const existing = (data.content_metadata || {});
178
+ // Only merge non-undefined fields
179
+ const cleaned = {};
180
+ for (const [k, v] of Object.entries(fields)) {
181
+ if (v !== undefined && v !== null)
182
+ cleaned[k] = v;
183
+ }
184
+ const merged = { ...existing, ...cleaned };
185
+ const { error: updateError } = await supabase
186
+ .from('bookmarks')
187
+ .update({ content_metadata: merged, ...extraColumns })
188
+ .eq('id', bookmarkId);
189
+ return { error: updateError?.message || null };
190
+ }
191
+ // ---------------------------------------------------------------------------
147
192
  // Helper: extract fields from content_metadata JSONB
148
193
  // ---------------------------------------------------------------------------
149
194
  function meta(row) {
@@ -194,6 +239,39 @@ function metaSummary(row) {
194
239
  aiTakeaway: m.ai_takeaway || [],
195
240
  };
196
241
  }
242
+ /** Flame-specific summary with countdown and AI triage fields */
243
+ function flameSummary(row) {
244
+ const m = row.content_metadata || {};
245
+ const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null;
246
+ const now = new Date();
247
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
248
+ const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10);
249
+ return {
250
+ id: row.id,
251
+ url: row.url,
252
+ title: row.title,
253
+ author: m.author || null,
254
+ platform: row.platform,
255
+ tags: m.tags || [],
256
+ createdAt: row.created_at,
257
+ expiresAt: row.countdown_expires_at,
258
+ remainingHours,
259
+ isBurning: remainingHours <= 6,
260
+ isCritical: remainingHours <= 1,
261
+ aiPositioning: m.ai_positioning || null,
262
+ aiDensity: m.ai_density || null,
263
+ aiMinutes: m.ai_minutes || null,
264
+ aiTakeaway: m.ai_takeaway || [],
265
+ aiStrategy: m.ai_strategy || null,
266
+ aiStrategyReason: m.ai_strategy_reason || null,
267
+ aiHowToRead: m.ai_how_to_read || null,
268
+ aiRelevance: m.ai_relevance || null,
269
+ aiNovelty: m.ai_novelty || null,
270
+ aiOverlap: m.ai_overlap || null,
271
+ aiHook: m.ai_hook || null,
272
+ aiAbout: m.ai_about || [],
273
+ };
274
+ }
197
275
  // ---------------------------------------------------------------------------
198
276
  // Tool handlers (all wrapped with rate limiting)
199
277
  // ---------------------------------------------------------------------------
@@ -528,6 +606,55 @@ async function handleSearchSparks(args) {
528
606
  });
529
607
  return textResult(JSON.stringify(results, null, 2));
530
608
  }
609
+ async function handleListFlame(args) {
610
+ const { data, error } = await supabase
611
+ .from('bookmarks')
612
+ .select('*')
613
+ .eq('status', 'active')
614
+ .order('created_at', { ascending: false })
615
+ .limit(args.limit || 20);
616
+ if (error)
617
+ return textResult(`Error: ${error.message}`);
618
+ // Filter out already expired ones (should be ash but not yet processed)
619
+ const now = new Date();
620
+ const results = (data || [])
621
+ .filter((row) => {
622
+ if (!row.countdown_expires_at)
623
+ return true;
624
+ return new Date(row.countdown_expires_at).getTime() > now.getTime();
625
+ })
626
+ .map(flameSummary);
627
+ return textResult(JSON.stringify(results, null, 2));
628
+ }
629
+ async function handleGetFlameDetail(args) {
630
+ const { data, error } = await supabase
631
+ .from('bookmarks')
632
+ .select('*')
633
+ .eq('id', args.id)
634
+ .eq('status', 'active')
635
+ .single();
636
+ if (error) {
637
+ return textResult(error.code === 'PGRST116'
638
+ ? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.`
639
+ : `Error: ${error.message}`);
640
+ }
641
+ // Return full detail including extracted content
642
+ const m = data.content_metadata || {};
643
+ const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null;
644
+ const now = new Date();
645
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
646
+ const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10);
647
+ const result = {
648
+ ...flameSummary(data),
649
+ extractedContent: m.extracted_content || null,
650
+ externalURL: m.external_url || null,
651
+ thumbnail: m.thumbnail || null,
652
+ aiFocus: m.ai_focus || null,
653
+ aiUse: m.ai_use || null,
654
+ aiBuzz: m.ai_buzz || null,
655
+ };
656
+ return textResult(JSON.stringify(result, null, 2));
657
+ }
531
658
  async function handleListVault(args) {
532
659
  let query = supabase
533
660
  .from('bookmarks')
@@ -546,6 +673,255 @@ async function handleListVault(args) {
546
673
  return textResult(JSON.stringify(results, null, 2));
547
674
  }
548
675
  // ---------------------------------------------------------------------------
676
+ // Layer 1: Status flow handlers (决策层)
677
+ // ---------------------------------------------------------------------------
678
+ async function handleMoveFlameToSpark(args) {
679
+ const { data, error } = await verifyBookmark(args.id, 'active');
680
+ if (error)
681
+ return textResult(`Error: ${error}`);
682
+ const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
683
+ const metaFields = { spark_expires_at: sparkExpiresAt };
684
+ if (args.spark_insight)
685
+ metaFields.spark_insight = args.spark_insight;
686
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
687
+ status: 'read',
688
+ read_at: new Date().toISOString(),
689
+ });
690
+ if (mergeErr)
691
+ return textResult(`Error: ${mergeErr}`);
692
+ return textResult(JSON.stringify({
693
+ success: true,
694
+ id: args.id,
695
+ title: data.title,
696
+ action: 'flame → spark',
697
+ sparkExpiresAt,
698
+ }, null, 2));
699
+ }
700
+ async function handleMoveFlameToAsh(args) {
701
+ const { data, error } = await verifyBookmark(args.id, 'active');
702
+ if (error)
703
+ return textResult(`Error: ${error}`);
704
+ const { error: updateErr } = await supabase
705
+ .from('bookmarks')
706
+ .update({ status: 'ash' })
707
+ .eq('id', args.id);
708
+ if (updateErr)
709
+ return textResult(`Error: ${updateErr.message}`);
710
+ return textResult(JSON.stringify({
711
+ success: true,
712
+ id: args.id,
713
+ title: data.title,
714
+ action: 'flame → ash',
715
+ reason: args.reason || null,
716
+ }, null, 2));
717
+ }
718
+ async function handleMoveSparkToVault(args) {
719
+ const { data, error } = await verifyBookmark(args.id, 'read');
720
+ if (error)
721
+ return textResult(`Error: ${error}`);
722
+ const metaFields = { vaulted_at: new Date().toISOString() };
723
+ if (args.vault_category)
724
+ metaFields.vault_category = args.vault_category;
725
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
726
+ status: 'absorbed',
727
+ });
728
+ if (mergeErr)
729
+ return textResult(`Error: ${mergeErr}`);
730
+ return textResult(JSON.stringify({
731
+ success: true,
732
+ id: args.id,
733
+ title: data.title,
734
+ action: 'spark → vault',
735
+ vaultCategory: args.vault_category || null,
736
+ }, null, 2));
737
+ }
738
+ async function handleMoveSparkToAsh(args) {
739
+ const { data, error } = await verifyBookmark(args.id, 'read');
740
+ if (error)
741
+ return textResult(`Error: ${error}`);
742
+ const { error: updateErr } = await supabase
743
+ .from('bookmarks')
744
+ .update({ status: 'ash' })
745
+ .eq('id', args.id);
746
+ if (updateErr)
747
+ return textResult(`Error: ${updateErr.message}`);
748
+ return textResult(JSON.stringify({
749
+ success: true,
750
+ id: args.id,
751
+ title: data.title,
752
+ action: 'spark → ash',
753
+ }, null, 2));
754
+ }
755
+ async function handleBatchTriageFlame(args) {
756
+ const results = [];
757
+ for (const decision of args.decisions) {
758
+ if (decision.action === 'spark') {
759
+ const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight });
760
+ const parsed = JSON.parse(res.content[0].text);
761
+ results.push({ id: decision.id, action: 'flame → spark', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title });
762
+ }
763
+ else {
764
+ const res = await handleMoveFlameToAsh({ id: decision.id });
765
+ const parsed = JSON.parse(res.content[0].text);
766
+ results.push({ id: decision.id, action: 'flame → ash', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title });
767
+ }
768
+ }
769
+ const succeeded = results.filter(r => r.success).length;
770
+ const failed = results.filter(r => !r.success).length;
771
+ return textResult(JSON.stringify({
772
+ summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
773
+ results,
774
+ }, null, 2));
775
+ }
776
+ // ---------------------------------------------------------------------------
777
+ // Layer 3: AI analysis writeback handler (分析层)
778
+ // ---------------------------------------------------------------------------
779
+ async function handleWriteBookmarkAnalysis(args) {
780
+ const { data, error } = await verifyBookmark(args.id);
781
+ if (error)
782
+ return textResult(`Error: ${error}`);
783
+ const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis);
784
+ if (mergeErr)
785
+ return textResult(`Error: ${mergeErr}`);
786
+ const fieldsWritten = Object.keys(args.analysis).filter(k => args.analysis[k] !== undefined);
787
+ return textResult(JSON.stringify({
788
+ success: true,
789
+ id: args.id,
790
+ title: data.title,
791
+ fieldsWritten,
792
+ }, null, 2));
793
+ }
794
+ // ---------------------------------------------------------------------------
795
+ // Layer 2: Collection handlers (组合层)
796
+ // ---------------------------------------------------------------------------
797
+ async function handleCreateCollection(args) {
798
+ // Get user ID from an existing bookmark (RLS ensures we only see our own)
799
+ const { data: sample } = await supabase
800
+ .from('bookmarks')
801
+ .select('user_id')
802
+ .limit(1)
803
+ .single();
804
+ if (!sample)
805
+ return textResult('Error: No bookmarks found — cannot determine user ID');
806
+ const bookmarkIds = args.bookmark_ids || [];
807
+ // Verify bookmark_ids exist if provided
808
+ if (bookmarkIds.length > 0) {
809
+ const { data: existing } = await supabase
810
+ .from('bookmarks')
811
+ .select('id')
812
+ .in('id', bookmarkIds);
813
+ const existingIds = new Set((existing || []).map((b) => b.id));
814
+ const missing = bookmarkIds.filter(id => !existingIds.has(id));
815
+ if (missing.length > 0) {
816
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`);
817
+ }
818
+ }
819
+ const { data, error } = await supabase
820
+ .from('collections')
821
+ .insert({
822
+ user_id: sample.user_id,
823
+ name: args.name,
824
+ bookmark_ids: bookmarkIds,
825
+ is_overview_stale: true,
826
+ })
827
+ .select()
828
+ .single();
829
+ if (error)
830
+ return textResult(`Error: ${error.message}`);
831
+ return textResult(JSON.stringify({
832
+ success: true,
833
+ id: data.id,
834
+ name: data.name,
835
+ articleCount: bookmarkIds.length,
836
+ }, null, 2));
837
+ }
838
+ async function handleAddToCollection(args) {
839
+ const { data: collection, error } = await supabase
840
+ .from('collections')
841
+ .select('*')
842
+ .eq('id', args.collection_id)
843
+ .single();
844
+ if (error) {
845
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
846
+ }
847
+ // Verify bookmark_ids exist
848
+ const { data: existing } = await supabase
849
+ .from('bookmarks')
850
+ .select('id')
851
+ .in('id', args.bookmark_ids);
852
+ const existingIds = new Set((existing || []).map((b) => b.id));
853
+ const missing = args.bookmark_ids.filter(id => !existingIds.has(id));
854
+ if (missing.length > 0) {
855
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`);
856
+ }
857
+ // Union with existing (deduplicate)
858
+ const currentIds = new Set(collection.bookmark_ids || []);
859
+ const newIds = args.bookmark_ids.filter(id => !currentIds.has(id));
860
+ const merged = [...(collection.bookmark_ids || []), ...newIds];
861
+ const { error: updateErr } = await supabase
862
+ .from('collections')
863
+ .update({ bookmark_ids: merged, is_overview_stale: true })
864
+ .eq('id', args.collection_id);
865
+ if (updateErr)
866
+ return textResult(`Error: ${updateErr.message}`);
867
+ return textResult(JSON.stringify({
868
+ success: true,
869
+ collectionId: args.collection_id,
870
+ name: collection.name,
871
+ added: newIds.length,
872
+ alreadyPresent: args.bookmark_ids.length - newIds.length,
873
+ totalArticles: merged.length,
874
+ }, null, 2));
875
+ }
876
+ async function handleRemoveFromCollection(args) {
877
+ const { data: collection, error } = await supabase
878
+ .from('collections')
879
+ .select('*')
880
+ .eq('id', args.collection_id)
881
+ .single();
882
+ if (error) {
883
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
884
+ }
885
+ const removeSet = new Set(args.bookmark_ids);
886
+ const filtered = (collection.bookmark_ids || []).filter((id) => !removeSet.has(id));
887
+ const removed = (collection.bookmark_ids || []).length - filtered.length;
888
+ const { error: updateErr } = await supabase
889
+ .from('collections')
890
+ .update({ bookmark_ids: filtered, is_overview_stale: true })
891
+ .eq('id', args.collection_id);
892
+ if (updateErr)
893
+ return textResult(`Error: ${updateErr.message}`);
894
+ return textResult(JSON.stringify({
895
+ success: true,
896
+ collectionId: args.collection_id,
897
+ name: collection.name,
898
+ removed,
899
+ totalArticles: filtered.length,
900
+ }, null, 2));
901
+ }
902
+ async function handleUpdateCollectionOverview(args) {
903
+ const { data: collection, error } = await supabase
904
+ .from('collections')
905
+ .select('id, name')
906
+ .eq('id', args.collection_id)
907
+ .single();
908
+ if (error) {
909
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
910
+ }
911
+ const { error: updateErr } = await supabase
912
+ .from('collections')
913
+ .update({ ai_overview: args.overview, is_overview_stale: false })
914
+ .eq('id', args.collection_id);
915
+ if (updateErr)
916
+ return textResult(`Error: ${updateErr.message}`);
917
+ return textResult(JSON.stringify({
918
+ success: true,
919
+ collectionId: args.collection_id,
920
+ name: collection.name,
921
+ overviewTheme: args.overview.theme,
922
+ }, null, 2));
923
+ }
924
+ // ---------------------------------------------------------------------------
549
925
  // Register tools
550
926
  // ---------------------------------------------------------------------------
551
927
  // @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
@@ -557,9 +933,81 @@ server.tool('get_bookmark', 'Get full details of a single bookmark including AI
557
933
  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
934
  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
935
  server.tool('list_categories', 'List all Vault categories with article counts', {}, rateLimited(handleListCategories));
936
+ server.tool('list_flame', '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.', { limit: zod_1.z.number().optional().describe('Max results (default 20)') }, rateLimited(handleListFlame));
937
+ server.tool('get_flame_detail', '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.', { id: zod_1.z.string().describe('Bookmark UUID') }, rateLimited(handleGetFlameDetail));
560
938
  server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, rateLimited(handleGetCollections));
561
939
  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));
562
940
  // ---------------------------------------------------------------------------
941
+ // Layer 1: Status flow tools (决策层)
942
+ // ---------------------------------------------------------------------------
943
+ server.tool('move_flame_to_spark', 'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.', {
944
+ id: zod_1.z.string().describe('Bookmark UUID'),
945
+ spark_insight: zod_1.z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
946
+ }, rateLimited(handleMoveFlameToSpark));
947
+ server.tool('move_flame_to_ash', 'Burn a Flame bookmark to Ash (not worth keeping).', {
948
+ id: zod_1.z.string().describe('Bookmark UUID'),
949
+ reason: zod_1.z.string().max(200).optional().describe('Why this was burned'),
950
+ }, rateLimited(handleMoveFlameToAsh));
951
+ server.tool('move_spark_to_vault', 'Promote a Spark bookmark to permanent Vault storage.', {
952
+ id: zod_1.z.string().describe('Bookmark UUID'),
953
+ vault_category: zod_1.z.string().max(100).optional().describe('Category to file under in the Vault'),
954
+ }, rateLimited(handleMoveSparkToVault));
955
+ server.tool('move_spark_to_ash', 'Burn a Spark bookmark to Ash (not valuable enough to vault).', {
956
+ id: zod_1.z.string().describe('Bookmark UUID'),
957
+ }, rateLimited(handleMoveSparkToAsh));
958
+ // @ts-expect-error — MCP SDK TS2589
959
+ server.tool('batch_triage_flame', 'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.', {
960
+ decisions: zod_1.z.array(zod_1.z.object({
961
+ id: zod_1.z.string().describe('Bookmark UUID'),
962
+ action: zod_1.z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
963
+ spark_insight: zod_1.z.string().max(500).optional().describe('Insight (only for spark action)'),
964
+ })).min(1).max(20).describe('Array of triage decisions'),
965
+ }, rateLimited(handleBatchTriageFlame));
966
+ // ---------------------------------------------------------------------------
967
+ // Layer 3: AI analysis writeback tools (分析层)
968
+ // ---------------------------------------------------------------------------
969
+ // @ts-expect-error — MCP SDK TS2589
970
+ server.tool('write_bookmark_analysis', '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 — existing data is preserved.', {
971
+ id: zod_1.z.string().describe('Bookmark UUID'),
972
+ analysis: zod_1.z.object({
973
+ ai_summary: zod_1.z.string().max(200).optional().describe('One-line summary'),
974
+ ai_strategy: zod_1.z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
975
+ ai_strategy_reason: zod_1.z.string().max(200).optional().describe('Why this strategy'),
976
+ ai_minutes: zod_1.z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
977
+ ai_takeaway: zod_1.z.array(zod_1.z.string().max(200)).max(5).optional().describe('Key takeaways'),
978
+ ai_relevance: zod_1.z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
979
+ ai_novelty: zod_1.z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
980
+ tags: zod_1.z.array(zod_1.z.string().max(50)).max(10).optional().describe('Topic tags'),
981
+ }).describe('Analysis fields to write'),
982
+ }, rateLimited(handleWriteBookmarkAnalysis));
983
+ // ---------------------------------------------------------------------------
984
+ // Layer 2: Collection tools (组合层)
985
+ // ---------------------------------------------------------------------------
986
+ // @ts-expect-error — MCP SDK TS2589
987
+ server.tool('create_collection', 'Create a new Collection to group related bookmarks together.', {
988
+ name: zod_1.z.string().min(1).max(200).describe('Collection name'),
989
+ bookmark_ids: zod_1.z.array(zod_1.z.string()).optional().describe('Initial bookmark UUIDs to include'),
990
+ }, rateLimited(handleCreateCollection));
991
+ // @ts-expect-error — MCP SDK TS2589
992
+ server.tool('add_to_collection', 'Add bookmarks to an existing Collection. Duplicates are silently ignored.', {
993
+ collection_id: zod_1.z.string().describe('Collection UUID'),
994
+ bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
995
+ }, rateLimited(handleAddToCollection));
996
+ server.tool('remove_from_collection', 'Remove bookmarks from a Collection.', {
997
+ collection_id: zod_1.z.string().describe('Collection UUID'),
998
+ bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).describe('Bookmark UUIDs to remove'),
999
+ }, rateLimited(handleRemoveFromCollection));
1000
+ // @ts-expect-error — MCP SDK TS2589
1001
+ server.tool('update_collection_overview', 'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).', {
1002
+ collection_id: zod_1.z.string().describe('Collection UUID'),
1003
+ overview: zod_1.z.object({
1004
+ theme: zod_1.z.string().describe('Overarching theme'),
1005
+ synthesis: zod_1.z.string().optional().describe('Cross-bookmark synthesis'),
1006
+ patterns: zod_1.z.array(zod_1.z.string()).optional().describe('Patterns identified'),
1007
+ gaps: zod_1.z.array(zod_1.z.string()).optional().describe('Knowledge gaps identified'),
1008
+ }).describe('AI-generated overview'),
1009
+ }, rateLimited(handleUpdateCollectionOverview));
1010
+ // ---------------------------------------------------------------------------
563
1011
  // Resource: burn://vault/bookmarks
564
1012
  // ---------------------------------------------------------------------------
565
1013
  server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burn-mcp-server",
3
- "version": "1.4.0",
3
+ "version": "2.0.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
@@ -153,7 +153,7 @@ function checkRateLimit(): string | null {
153
153
 
154
154
  const server = new McpServer({
155
155
  name: 'burn-mcp-server',
156
- version: '1.3.0',
156
+ version: '2.0.0',
157
157
  })
158
158
 
159
159
  // ---------------------------------------------------------------------------
@@ -164,6 +164,66 @@ function textResult(text: string) {
164
164
  return { content: [{ type: 'text' as const, text }] }
165
165
  }
166
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Helper: verify bookmark exists and has expected status
169
+ // ---------------------------------------------------------------------------
170
+
171
+ async function verifyBookmark(
172
+ id: string,
173
+ expectedStatus?: string | string[]
174
+ ): Promise<{ data: any; error: string | null }> {
175
+ const { data, error } = await supabase
176
+ .from('bookmarks')
177
+ .select('*')
178
+ .eq('id', id)
179
+ .single()
180
+
181
+ if (error) return { data: null, error: error.code === 'PGRST116' ? 'Bookmark not found' : error.message }
182
+
183
+ if (expectedStatus) {
184
+ const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus]
185
+ if (!allowed.includes(data.status)) {
186
+ const statusLabels: Record<string, string> = { active: 'Flame', read: 'Spark', absorbed: 'Vault', ash: 'Ash' }
187
+ return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map(s => statusLabels[s] || s).join(' or ')})` }
188
+ }
189
+ }
190
+
191
+ return { data, error: null }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Helper: merge fields into content_metadata JSONB without overwriting
196
+ // ---------------------------------------------------------------------------
197
+
198
+ async function mergeContentMetadata(
199
+ bookmarkId: string,
200
+ fields: Record<string, unknown>,
201
+ extraColumns?: Record<string, unknown>
202
+ ): Promise<{ error: string | null }> {
203
+ const { data, error } = await supabase
204
+ .from('bookmarks')
205
+ .select('content_metadata')
206
+ .eq('id', bookmarkId)
207
+ .single()
208
+
209
+ if (error) return { error: error.message }
210
+
211
+ const existing = (data.content_metadata || {}) as Record<string, unknown>
212
+ // Only merge non-undefined fields
213
+ const cleaned: Record<string, unknown> = {}
214
+ for (const [k, v] of Object.entries(fields)) {
215
+ if (v !== undefined && v !== null) cleaned[k] = v
216
+ }
217
+ const merged = { ...existing, ...cleaned }
218
+
219
+ const { error: updateError } = await supabase
220
+ .from('bookmarks')
221
+ .update({ content_metadata: merged, ...extraColumns })
222
+ .eq('id', bookmarkId)
223
+
224
+ return { error: updateError?.message || null }
225
+ }
226
+
167
227
  // ---------------------------------------------------------------------------
168
228
  // Helper: extract fields from content_metadata JSONB
169
229
  // ---------------------------------------------------------------------------
@@ -218,6 +278,41 @@ function metaSummary(row: any): any {
218
278
  }
219
279
  }
220
280
 
281
+ /** Flame-specific summary with countdown and AI triage fields */
282
+ function flameSummary(row: any): any {
283
+ const m = row.content_metadata || {}
284
+ const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null
285
+ const now = new Date()
286
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0
287
+ const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10)
288
+
289
+ return {
290
+ id: row.id,
291
+ url: row.url,
292
+ title: row.title,
293
+ author: m.author || null,
294
+ platform: row.platform,
295
+ tags: m.tags || [],
296
+ createdAt: row.created_at,
297
+ expiresAt: row.countdown_expires_at,
298
+ remainingHours,
299
+ isBurning: remainingHours <= 6,
300
+ isCritical: remainingHours <= 1,
301
+ aiPositioning: m.ai_positioning || null,
302
+ aiDensity: m.ai_density || null,
303
+ aiMinutes: m.ai_minutes || null,
304
+ aiTakeaway: m.ai_takeaway || [],
305
+ aiStrategy: m.ai_strategy || null,
306
+ aiStrategyReason: m.ai_strategy_reason || null,
307
+ aiHowToRead: m.ai_how_to_read || null,
308
+ aiRelevance: m.ai_relevance || null,
309
+ aiNovelty: m.ai_novelty || null,
310
+ aiOverlap: m.ai_overlap || null,
311
+ aiHook: m.ai_hook || null,
312
+ aiAbout: m.ai_about || [],
313
+ }
314
+ }
315
+
221
316
  // ---------------------------------------------------------------------------
222
317
  // Tool handlers (all wrapped with rate limiting)
223
318
  // ---------------------------------------------------------------------------
@@ -590,6 +685,64 @@ async function handleSearchSparks(args: { query: string; limit?: number }) {
590
685
  return textResult(JSON.stringify(results, null, 2))
591
686
  }
592
687
 
688
+ async function handleListFlame(args: { limit?: number }) {
689
+ const { data, error } = await supabase
690
+ .from('bookmarks')
691
+ .select('*')
692
+ .eq('status', 'active')
693
+ .order('created_at', { ascending: false })
694
+ .limit(args.limit || 20)
695
+
696
+ if (error) return textResult(`Error: ${error.message}`)
697
+
698
+ // Filter out already expired ones (should be ash but not yet processed)
699
+ const now = new Date()
700
+ const results = (data || [])
701
+ .filter((row: any) => {
702
+ if (!row.countdown_expires_at) return true
703
+ return new Date(row.countdown_expires_at).getTime() > now.getTime()
704
+ })
705
+ .map(flameSummary)
706
+
707
+ return textResult(JSON.stringify(results, null, 2))
708
+ }
709
+
710
+ async function handleGetFlameDetail(args: { id: string }) {
711
+ const { data, error } = await supabase
712
+ .from('bookmarks')
713
+ .select('*')
714
+ .eq('id', args.id)
715
+ .eq('status', 'active')
716
+ .single()
717
+
718
+ if (error) {
719
+ return textResult(
720
+ error.code === 'PGRST116'
721
+ ? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.`
722
+ : `Error: ${error.message}`
723
+ )
724
+ }
725
+
726
+ // Return full detail including extracted content
727
+ const m = data.content_metadata || {}
728
+ const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null
729
+ const now = new Date()
730
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0
731
+ const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10)
732
+
733
+ const result = {
734
+ ...flameSummary(data),
735
+ extractedContent: m.extracted_content || null,
736
+ externalURL: m.external_url || null,
737
+ thumbnail: m.thumbnail || null,
738
+ aiFocus: m.ai_focus || null,
739
+ aiUse: m.ai_use || null,
740
+ aiBuzz: m.ai_buzz || null,
741
+ }
742
+
743
+ return textResult(JSON.stringify(result, null, 2))
744
+ }
745
+
593
746
  async function handleListVault(args: { limit?: number; category?: string }) {
594
747
  let query = supabase
595
748
  .from('bookmarks')
@@ -614,6 +767,311 @@ async function handleListVault(args: { limit?: number; category?: string }) {
614
767
  return textResult(JSON.stringify(results, null, 2))
615
768
  }
616
769
 
770
+ // ---------------------------------------------------------------------------
771
+ // Layer 1: Status flow handlers (决策层)
772
+ // ---------------------------------------------------------------------------
773
+
774
+ async function handleMoveFlameToSpark(args: { id: string; spark_insight?: string }) {
775
+ const { data, error } = await verifyBookmark(args.id, 'active')
776
+ if (error) return textResult(`Error: ${error}`)
777
+
778
+ const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
779
+ const metaFields: Record<string, unknown> = { spark_expires_at: sparkExpiresAt }
780
+ if (args.spark_insight) metaFields.spark_insight = args.spark_insight
781
+
782
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
783
+ status: 'read',
784
+ read_at: new Date().toISOString(),
785
+ })
786
+ if (mergeErr) return textResult(`Error: ${mergeErr}`)
787
+
788
+ return textResult(JSON.stringify({
789
+ success: true,
790
+ id: args.id,
791
+ title: data.title,
792
+ action: 'flame → spark',
793
+ sparkExpiresAt,
794
+ }, null, 2))
795
+ }
796
+
797
+ async function handleMoveFlameToAsh(args: { id: string; reason?: string }) {
798
+ const { data, error } = await verifyBookmark(args.id, 'active')
799
+ if (error) return textResult(`Error: ${error}`)
800
+
801
+ const { error: updateErr } = await supabase
802
+ .from('bookmarks')
803
+ .update({ status: 'ash' })
804
+ .eq('id', args.id)
805
+
806
+ if (updateErr) return textResult(`Error: ${updateErr.message}`)
807
+
808
+ return textResult(JSON.stringify({
809
+ success: true,
810
+ id: args.id,
811
+ title: data.title,
812
+ action: 'flame → ash',
813
+ reason: args.reason || null,
814
+ }, null, 2))
815
+ }
816
+
817
+ async function handleMoveSparkToVault(args: { id: string; vault_category?: string }) {
818
+ const { data, error } = await verifyBookmark(args.id, 'read')
819
+ if (error) return textResult(`Error: ${error}`)
820
+
821
+ const metaFields: Record<string, unknown> = { vaulted_at: new Date().toISOString() }
822
+ if (args.vault_category) metaFields.vault_category = args.vault_category
823
+
824
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
825
+ status: 'absorbed',
826
+ })
827
+ if (mergeErr) return textResult(`Error: ${mergeErr}`)
828
+
829
+ return textResult(JSON.stringify({
830
+ success: true,
831
+ id: args.id,
832
+ title: data.title,
833
+ action: 'spark → vault',
834
+ vaultCategory: args.vault_category || null,
835
+ }, null, 2))
836
+ }
837
+
838
+ async function handleMoveSparkToAsh(args: { id: string }) {
839
+ const { data, error } = await verifyBookmark(args.id, 'read')
840
+ if (error) return textResult(`Error: ${error}`)
841
+
842
+ const { error: updateErr } = await supabase
843
+ .from('bookmarks')
844
+ .update({ status: 'ash' })
845
+ .eq('id', args.id)
846
+
847
+ if (updateErr) return textResult(`Error: ${updateErr.message}`)
848
+
849
+ return textResult(JSON.stringify({
850
+ success: true,
851
+ id: args.id,
852
+ title: data.title,
853
+ action: 'spark → ash',
854
+ }, null, 2))
855
+ }
856
+
857
+ async function handleBatchTriageFlame(args: { decisions: Array<{ id: string; action: 'spark' | 'ash'; spark_insight?: string }> }) {
858
+ const results: Array<{ id: string; action: string; success: boolean; error?: string; title?: string }> = []
859
+
860
+ for (const decision of args.decisions) {
861
+ if (decision.action === 'spark') {
862
+ const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight })
863
+ const parsed = JSON.parse(res.content[0].text)
864
+ results.push({ id: decision.id, action: 'flame → spark', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title })
865
+ } else {
866
+ const res = await handleMoveFlameToAsh({ id: decision.id })
867
+ const parsed = JSON.parse(res.content[0].text)
868
+ results.push({ id: decision.id, action: 'flame → ash', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title })
869
+ }
870
+ }
871
+
872
+ const succeeded = results.filter(r => r.success).length
873
+ const failed = results.filter(r => !r.success).length
874
+
875
+ return textResult(JSON.stringify({
876
+ summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
877
+ results,
878
+ }, null, 2))
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // Layer 3: AI analysis writeback handler (分析层)
883
+ // ---------------------------------------------------------------------------
884
+
885
+ async function handleWriteBookmarkAnalysis(args: {
886
+ id: string
887
+ analysis: {
888
+ ai_summary?: string
889
+ ai_strategy?: string
890
+ ai_strategy_reason?: string
891
+ ai_minutes?: number
892
+ ai_takeaway?: string[]
893
+ ai_relevance?: number
894
+ ai_novelty?: number
895
+ tags?: string[]
896
+ }
897
+ }) {
898
+ const { data, error } = await verifyBookmark(args.id)
899
+ if (error) return textResult(`Error: ${error}`)
900
+
901
+ const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis)
902
+ if (mergeErr) return textResult(`Error: ${mergeErr}`)
903
+
904
+ const fieldsWritten = Object.keys(args.analysis).filter(k => (args.analysis as any)[k] !== undefined)
905
+
906
+ return textResult(JSON.stringify({
907
+ success: true,
908
+ id: args.id,
909
+ title: data.title,
910
+ fieldsWritten,
911
+ }, null, 2))
912
+ }
913
+
914
+ // ---------------------------------------------------------------------------
915
+ // Layer 2: Collection handlers (组合层)
916
+ // ---------------------------------------------------------------------------
917
+
918
+ async function handleCreateCollection(args: { name: string; bookmark_ids?: string[] }) {
919
+ // Get user ID from an existing bookmark (RLS ensures we only see our own)
920
+ const { data: sample } = await supabase
921
+ .from('bookmarks')
922
+ .select('user_id')
923
+ .limit(1)
924
+ .single()
925
+
926
+ if (!sample) return textResult('Error: No bookmarks found — cannot determine user ID')
927
+
928
+ const bookmarkIds = args.bookmark_ids || []
929
+
930
+ // Verify bookmark_ids exist if provided
931
+ if (bookmarkIds.length > 0) {
932
+ const { data: existing } = await supabase
933
+ .from('bookmarks')
934
+ .select('id')
935
+ .in('id', bookmarkIds)
936
+
937
+ const existingIds = new Set((existing || []).map((b: any) => b.id))
938
+ const missing = bookmarkIds.filter(id => !existingIds.has(id))
939
+ if (missing.length > 0) {
940
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`)
941
+ }
942
+ }
943
+
944
+ const { data, error } = await supabase
945
+ .from('collections')
946
+ .insert({
947
+ user_id: sample.user_id,
948
+ name: args.name,
949
+ bookmark_ids: bookmarkIds,
950
+ is_overview_stale: true,
951
+ })
952
+ .select()
953
+ .single()
954
+
955
+ if (error) return textResult(`Error: ${error.message}`)
956
+
957
+ return textResult(JSON.stringify({
958
+ success: true,
959
+ id: data.id,
960
+ name: data.name,
961
+ articleCount: bookmarkIds.length,
962
+ }, null, 2))
963
+ }
964
+
965
+ async function handleAddToCollection(args: { collection_id: string; bookmark_ids: string[] }) {
966
+ const { data: collection, error } = await supabase
967
+ .from('collections')
968
+ .select('*')
969
+ .eq('id', args.collection_id)
970
+ .single()
971
+
972
+ if (error) {
973
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
974
+ }
975
+
976
+ // Verify bookmark_ids exist
977
+ const { data: existing } = await supabase
978
+ .from('bookmarks')
979
+ .select('id')
980
+ .in('id', args.bookmark_ids)
981
+
982
+ const existingIds = new Set((existing || []).map((b: any) => b.id))
983
+ const missing = args.bookmark_ids.filter(id => !existingIds.has(id))
984
+ if (missing.length > 0) {
985
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`)
986
+ }
987
+
988
+ // Union with existing (deduplicate)
989
+ const currentIds = new Set(collection.bookmark_ids || [])
990
+ const newIds = args.bookmark_ids.filter(id => !currentIds.has(id))
991
+ const merged = [...(collection.bookmark_ids || []), ...newIds]
992
+
993
+ const { error: updateErr } = await supabase
994
+ .from('collections')
995
+ .update({ bookmark_ids: merged, is_overview_stale: true })
996
+ .eq('id', args.collection_id)
997
+
998
+ if (updateErr) return textResult(`Error: ${updateErr.message}`)
999
+
1000
+ return textResult(JSON.stringify({
1001
+ success: true,
1002
+ collectionId: args.collection_id,
1003
+ name: collection.name,
1004
+ added: newIds.length,
1005
+ alreadyPresent: args.bookmark_ids.length - newIds.length,
1006
+ totalArticles: merged.length,
1007
+ }, null, 2))
1008
+ }
1009
+
1010
+ async function handleRemoveFromCollection(args: { collection_id: string; bookmark_ids: string[] }) {
1011
+ const { data: collection, error } = await supabase
1012
+ .from('collections')
1013
+ .select('*')
1014
+ .eq('id', args.collection_id)
1015
+ .single()
1016
+
1017
+ if (error) {
1018
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
1019
+ }
1020
+
1021
+ const removeSet = new Set(args.bookmark_ids)
1022
+ const filtered = (collection.bookmark_ids || []).filter((id: string) => !removeSet.has(id))
1023
+ const removed = (collection.bookmark_ids || []).length - filtered.length
1024
+
1025
+ const { error: updateErr } = await supabase
1026
+ .from('collections')
1027
+ .update({ bookmark_ids: filtered, is_overview_stale: true })
1028
+ .eq('id', args.collection_id)
1029
+
1030
+ if (updateErr) return textResult(`Error: ${updateErr.message}`)
1031
+
1032
+ return textResult(JSON.stringify({
1033
+ success: true,
1034
+ collectionId: args.collection_id,
1035
+ name: collection.name,
1036
+ removed,
1037
+ totalArticles: filtered.length,
1038
+ }, null, 2))
1039
+ }
1040
+
1041
+ async function handleUpdateCollectionOverview(args: {
1042
+ collection_id: string
1043
+ overview: {
1044
+ theme: string
1045
+ synthesis?: string
1046
+ patterns?: string[]
1047
+ gaps?: string[]
1048
+ }
1049
+ }) {
1050
+ const { data: collection, error } = await supabase
1051
+ .from('collections')
1052
+ .select('id, name')
1053
+ .eq('id', args.collection_id)
1054
+ .single()
1055
+
1056
+ if (error) {
1057
+ return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`)
1058
+ }
1059
+
1060
+ const { error: updateErr } = await supabase
1061
+ .from('collections')
1062
+ .update({ ai_overview: args.overview, is_overview_stale: false })
1063
+ .eq('id', args.collection_id)
1064
+
1065
+ if (updateErr) return textResult(`Error: ${updateErr.message}`)
1066
+
1067
+ return textResult(JSON.stringify({
1068
+ success: true,
1069
+ collectionId: args.collection_id,
1070
+ name: collection.name,
1071
+ overviewTheme: args.overview.theme,
1072
+ }, null, 2))
1073
+ }
1074
+
617
1075
  // ---------------------------------------------------------------------------
618
1076
  // Register tools
619
1077
  // ---------------------------------------------------------------------------
@@ -675,6 +1133,20 @@ server.tool(
675
1133
  rateLimited(handleListCategories)
676
1134
  )
677
1135
 
1136
+ server.tool(
1137
+ 'list_flame',
1138
+ '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.',
1139
+ { limit: z.number().optional().describe('Max results (default 20)') },
1140
+ rateLimited(handleListFlame)
1141
+ )
1142
+
1143
+ server.tool(
1144
+ 'get_flame_detail',
1145
+ '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.',
1146
+ { id: z.string().describe('Bookmark UUID') },
1147
+ rateLimited(handleGetFlameDetail)
1148
+ )
1149
+
678
1150
  server.tool(
679
1151
  'get_collections',
680
1152
  'List all your Collections with article counts and AI overview themes',
@@ -689,6 +1161,139 @@ server.tool(
689
1161
  rateLimited(handleGetCollectionOverview)
690
1162
  )
691
1163
 
1164
+ // ---------------------------------------------------------------------------
1165
+ // Layer 1: Status flow tools (决策层)
1166
+ // ---------------------------------------------------------------------------
1167
+
1168
+ server.tool(
1169
+ 'move_flame_to_spark',
1170
+ 'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.',
1171
+ {
1172
+ id: z.string().describe('Bookmark UUID'),
1173
+ spark_insight: z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
1174
+ },
1175
+ rateLimited(handleMoveFlameToSpark)
1176
+ )
1177
+
1178
+ server.tool(
1179
+ 'move_flame_to_ash',
1180
+ 'Burn a Flame bookmark to Ash (not worth keeping).',
1181
+ {
1182
+ id: z.string().describe('Bookmark UUID'),
1183
+ reason: z.string().max(200).optional().describe('Why this was burned'),
1184
+ },
1185
+ rateLimited(handleMoveFlameToAsh)
1186
+ )
1187
+
1188
+ server.tool(
1189
+ 'move_spark_to_vault',
1190
+ 'Promote a Spark bookmark to permanent Vault storage.',
1191
+ {
1192
+ id: z.string().describe('Bookmark UUID'),
1193
+ vault_category: z.string().max(100).optional().describe('Category to file under in the Vault'),
1194
+ },
1195
+ rateLimited(handleMoveSparkToVault)
1196
+ )
1197
+
1198
+ server.tool(
1199
+ 'move_spark_to_ash',
1200
+ 'Burn a Spark bookmark to Ash (not valuable enough to vault).',
1201
+ {
1202
+ id: z.string().describe('Bookmark UUID'),
1203
+ },
1204
+ rateLimited(handleMoveSparkToAsh)
1205
+ )
1206
+
1207
+ // @ts-expect-error — MCP SDK TS2589
1208
+ server.tool(
1209
+ 'batch_triage_flame',
1210
+ 'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.',
1211
+ {
1212
+ decisions: z.array(z.object({
1213
+ id: z.string().describe('Bookmark UUID'),
1214
+ action: z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
1215
+ spark_insight: z.string().max(500).optional().describe('Insight (only for spark action)'),
1216
+ })).min(1).max(20).describe('Array of triage decisions'),
1217
+ },
1218
+ rateLimited(handleBatchTriageFlame)
1219
+ )
1220
+
1221
+ // ---------------------------------------------------------------------------
1222
+ // Layer 3: AI analysis writeback tools (分析层)
1223
+ // ---------------------------------------------------------------------------
1224
+
1225
+ // @ts-expect-error — MCP SDK TS2589
1226
+ server.tool(
1227
+ 'write_bookmark_analysis',
1228
+ '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 — existing data is preserved.',
1229
+ {
1230
+ id: z.string().describe('Bookmark UUID'),
1231
+ analysis: z.object({
1232
+ ai_summary: z.string().max(200).optional().describe('One-line summary'),
1233
+ ai_strategy: z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
1234
+ ai_strategy_reason: z.string().max(200).optional().describe('Why this strategy'),
1235
+ ai_minutes: z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
1236
+ ai_takeaway: z.array(z.string().max(200)).max(5).optional().describe('Key takeaways'),
1237
+ ai_relevance: z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
1238
+ ai_novelty: z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
1239
+ tags: z.array(z.string().max(50)).max(10).optional().describe('Topic tags'),
1240
+ }).describe('Analysis fields to write'),
1241
+ },
1242
+ rateLimited(handleWriteBookmarkAnalysis)
1243
+ )
1244
+
1245
+ // ---------------------------------------------------------------------------
1246
+ // Layer 2: Collection tools (组合层)
1247
+ // ---------------------------------------------------------------------------
1248
+
1249
+ // @ts-expect-error — MCP SDK TS2589
1250
+ server.tool(
1251
+ 'create_collection',
1252
+ 'Create a new Collection to group related bookmarks together.',
1253
+ {
1254
+ name: z.string().min(1).max(200).describe('Collection name'),
1255
+ bookmark_ids: z.array(z.string()).optional().describe('Initial bookmark UUIDs to include'),
1256
+ },
1257
+ rateLimited(handleCreateCollection)
1258
+ )
1259
+
1260
+ // @ts-expect-error — MCP SDK TS2589
1261
+ server.tool(
1262
+ 'add_to_collection',
1263
+ 'Add bookmarks to an existing Collection. Duplicates are silently ignored.',
1264
+ {
1265
+ collection_id: z.string().describe('Collection UUID'),
1266
+ bookmark_ids: z.array(z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
1267
+ },
1268
+ rateLimited(handleAddToCollection)
1269
+ )
1270
+
1271
+ server.tool(
1272
+ 'remove_from_collection',
1273
+ 'Remove bookmarks from a Collection.',
1274
+ {
1275
+ collection_id: z.string().describe('Collection UUID'),
1276
+ bookmark_ids: z.array(z.string()).min(1).describe('Bookmark UUIDs to remove'),
1277
+ },
1278
+ rateLimited(handleRemoveFromCollection)
1279
+ )
1280
+
1281
+ // @ts-expect-error — MCP SDK TS2589
1282
+ server.tool(
1283
+ 'update_collection_overview',
1284
+ 'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).',
1285
+ {
1286
+ collection_id: z.string().describe('Collection UUID'),
1287
+ overview: z.object({
1288
+ theme: z.string().describe('Overarching theme'),
1289
+ synthesis: z.string().optional().describe('Cross-bookmark synthesis'),
1290
+ patterns: z.array(z.string()).optional().describe('Patterns identified'),
1291
+ gaps: z.array(z.string()).optional().describe('Knowledge gaps identified'),
1292
+ }).describe('AI-generated overview'),
1293
+ },
1294
+ rateLimited(handleUpdateCollectionOverview)
1295
+ )
1296
+
692
1297
  // ---------------------------------------------------------------------------
693
1298
  // Resource: burn://vault/bookmarks
694
1299
  // ---------------------------------------------------------------------------