burn-mcp-server 1.4.0 → 2.0.1
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 +87 -30
- package/dist/index.js +449 -1
- package/package.json +1 -1
- package/src/index.ts +606 -1
package/README.md
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
# Burn MCP Server
|
|
1
|
+
# Burn — Personal Knowledge Base MCP Server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your reading data as an AI-accessible knowledge base. 22 tools for Claude, Cursor, Windsurf, and any MCP-compatible agent.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## How it works
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Burn triages your reading with a 24h timer:
|
|
8
|
+
- **Flame** → New links. 24h to read or they burn.
|
|
9
|
+
- **Spark** → You read it. Stays 30 days.
|
|
10
|
+
- **Vault** → Permanent. Your curated knowledge.
|
|
11
|
+
- **Ash** → Expired. They had their chance.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
The MCP server lets your AI agent search, triage, organize, and analyze everything you've saved.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
10
16
|
|
|
11
|
-
###
|
|
17
|
+
### 1. Get your token
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
Open Burn App → Settings → MCP Server → **Copy Access Token**
|
|
20
|
+
|
|
21
|
+
### 2. Add to Claude Desktop
|
|
14
22
|
|
|
15
23
|
```json
|
|
16
24
|
{
|
|
@@ -19,50 +27,99 @@ Add to your `~/.config/claude/claude_desktop_config.json`:
|
|
|
19
27
|
"command": "npx",
|
|
20
28
|
"args": ["burn-mcp-server"],
|
|
21
29
|
"env": {
|
|
22
|
-
"
|
|
30
|
+
"BURN_MCP_TOKEN": "<your-token>"
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
}
|
|
27
35
|
```
|
|
28
36
|
|
|
29
|
-
### 3.
|
|
37
|
+
### 3. Start asking
|
|
38
|
+
|
|
39
|
+
- "What did I save about system design?"
|
|
40
|
+
- "Triage my Flame — what should I keep?"
|
|
41
|
+
- "Create a collection from my AI bookmarks"
|
|
42
|
+
|
|
43
|
+
## Tools (22)
|
|
44
|
+
|
|
45
|
+
### Search & Read
|
|
46
|
+
|
|
47
|
+
| Tool | What it does |
|
|
48
|
+
|------|-------------|
|
|
49
|
+
| `search_vault` | Search permanent bookmarks by keyword |
|
|
50
|
+
| `list_vault` | List Vault bookmarks by category |
|
|
51
|
+
| `list_sparks` | List recently read bookmarks (30-day window) |
|
|
52
|
+
| `search_sparks` | Search Sparks by keyword |
|
|
53
|
+
| `list_flame` | List inbox — what's about to burn |
|
|
54
|
+
| `get_flame_detail` | Full detail on a Flame bookmark |
|
|
55
|
+
| `get_bookmark` | Get any bookmark by ID |
|
|
56
|
+
| `get_article_content` | Get full article content + analysis |
|
|
57
|
+
| `fetch_content` | Fetch content from a URL (X, Reddit, YouTube, WeChat) |
|
|
58
|
+
| `list_categories` | All Vault categories with counts |
|
|
59
|
+
| `get_collections` | List all Collections |
|
|
60
|
+
| `get_collection_overview` | Collection detail with AI overview |
|
|
61
|
+
|
|
62
|
+
### Triage (Agent as your filter)
|
|
63
|
+
|
|
64
|
+
| Tool | What it does |
|
|
65
|
+
|------|-------------|
|
|
66
|
+
| `move_flame_to_spark` | Keep it — worth reading. Optional insight. |
|
|
67
|
+
| `move_flame_to_ash` | Burn it. Optional reason. |
|
|
68
|
+
| `move_spark_to_vault` | Promote to permanent. Optional category. |
|
|
69
|
+
| `move_spark_to_ash` | Not valuable enough to keep. |
|
|
70
|
+
| `batch_triage_flame` | Triage up to 20 at once. |
|
|
71
|
+
|
|
72
|
+
### Collections (Agent as your curator)
|
|
30
73
|
|
|
31
|
-
|
|
74
|
+
| Tool | What it does |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| `create_collection` | Create a topic bundle with initial bookmarks |
|
|
77
|
+
| `add_to_collection` | Add bookmarks (deduplicates) |
|
|
78
|
+
| `remove_from_collection` | Remove bookmarks |
|
|
79
|
+
| `update_collection_overview` | Write AI overview (theme, synthesis, gaps) |
|
|
32
80
|
|
|
33
|
-
|
|
81
|
+
### Analysis (Agent as your analyst)
|
|
34
82
|
|
|
35
|
-
| Tool |
|
|
83
|
+
| Tool | What it does |
|
|
36
84
|
|------|-------------|
|
|
37
|
-
| `
|
|
38
|
-
| `get_bookmark` | Get full details of a single bookmark |
|
|
39
|
-
| `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) |
|
|
85
|
+
| `write_bookmark_analysis` | Write structured analysis back to a bookmark |
|
|
42
86
|
|
|
43
|
-
##
|
|
87
|
+
## Resources
|
|
44
88
|
|
|
45
|
-
| URI |
|
|
46
|
-
|
|
89
|
+
| URI | Content |
|
|
90
|
+
|-----|---------|
|
|
47
91
|
| `burn://vault/bookmarks` | All Vault bookmarks (JSON) |
|
|
48
92
|
| `burn://vault/categories` | Category list (JSON) |
|
|
49
93
|
|
|
50
|
-
##
|
|
94
|
+
## Use Cases
|
|
95
|
+
|
|
96
|
+
**Personal knowledge management** — Your agent searches your reading history to answer questions, find patterns, and surface forgotten gems.
|
|
97
|
+
|
|
98
|
+
**Research workflows** — Create collections on topics you're exploring. Agent writes overviews synthesizing your sources.
|
|
51
99
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
-
|
|
55
|
-
- "What are the main topics in my knowledge base?"
|
|
100
|
+
**Reading triage** — Agent reviews your Flame inbox, reads the content, decides what's worth keeping based on your interests.
|
|
101
|
+
|
|
102
|
+
**Cross-tool intelligence** — Use with Claude Code, Cursor, or Windsurf. Your bookmarks become context for coding, writing, and thinking.
|
|
56
103
|
|
|
57
104
|
## Environment Variables
|
|
58
105
|
|
|
59
106
|
| Variable | Required | Description |
|
|
60
107
|
|----------|----------|-------------|
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
108
|
+
| `BURN_MCP_TOKEN` | Yes* | Long-lived MCP token (recommended) |
|
|
109
|
+
| `BURN_SUPABASE_TOKEN` | Yes* | Legacy JWT token (still supported) |
|
|
110
|
+
| `BURN_API_URL` | No | Custom API URL (default: production) |
|
|
111
|
+
|
|
112
|
+
*One of `BURN_MCP_TOKEN` or `BURN_SUPABASE_TOKEN` required.
|
|
63
113
|
|
|
64
114
|
## Security
|
|
65
115
|
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
116
|
+
- Token scoped to your data only (Row Level Security)
|
|
117
|
+
- Status flow enforced: Flame → Spark → Vault, or → Ash
|
|
118
|
+
- Rate limit: 30 calls/min per session
|
|
119
|
+
- Tokens expire after 30 days
|
|
120
|
+
|
|
121
|
+
## Links
|
|
122
|
+
|
|
123
|
+
- **App**: [burn451.app](https://burn451.app)
|
|
124
|
+
- **npm**: [burn-mcp-server](https://www.npmjs.com/package/burn-mcp-server)
|
|
125
|
+
- **Chrome Extension**: Search "Bookmark Autopsy" on Chrome Web Store
|
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
|
// ---------------------------------------------------------------------------
|