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 +62 -12
- package/dist/index.js +449 -1
- package/package.json +1 -1
- package/src/index.ts +606 -1
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
|
|
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
|
-
"
|
|
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
|
-
| `
|
|
41
|
-
| `
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
| `
|
|
62
|
-
| `
|
|
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**
|
|
67
|
-
-
|
|
68
|
-
-
|
|
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: '
|
|
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
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: '
|
|
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
|
// ---------------------------------------------------------------------------
|