burn-mcp-server 2.0.4 → 2.0.6

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/glama.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "maintainers": ["Fisher521"]
3
+ }
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "burn-mcp-server",
3
- "version": "2.0.4",
4
- "mcpName": "io.github.fisher521/burn-mcp-server",
5
- "description": "MCP Server for Burn 22 tools to let your AI agent search, triage, and organize your reading. Works with Claude, Cursor, Windsurf.",
3
+ "version": "2.0.6",
4
+ "mcpName": "io.github.Fisher521/burn-mcp-server",
5
+ "description": "Burn MCP give your AI agent a reading workflow. 26 tools to search, triage, burn, vault, and analyze your saved articles. Works with Claude, Cursor, Windsurf.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "burn-mcp": "dist/index.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "tsc",
11
+ "build": "esbuild src/index.ts --bundle=false --platform=node --target=node18 --outdir=dist --format=cjs",
12
+ "typecheck": "node --max-old-space-size=4096 node_modules/typescript/bin/tsc --noEmit",
12
13
  "start": "node dist/index.js"
13
14
  },
14
15
  "keywords": [
@@ -49,7 +50,8 @@
49
50
  "zod": "^3.22.0"
50
51
  },
51
52
  "devDependencies": {
52
- "typescript": "^5.3.0",
53
- "@types/node": "^20.0.0"
53
+ "@types/node": "^20.0.0",
54
+ "esbuild": "^0.28.0",
55
+ "typescript": "^5.3.0"
54
56
  }
55
57
  }
package/server.json CHANGED
@@ -1,27 +1,28 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.fisher521/burn-mcp-server",
4
- "description": "22 tools to let your AI agent search, triage, and organize your reading list.",
3
+ "name": "io.github.Fisher521/burn-mcp-server",
4
+ "description": "AI-powered reading triage MCP. 26 tools with a 24h burn timer. Read less, absorb more.",
5
5
  "repository": {
6
6
  "url": "https://github.com/Fisher521/burn-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.0.2",
9
+ "websiteUrl": "https://burn451.cloud",
10
+ "version": "2.0.6",
10
11
  "packages": [
11
12
  {
12
13
  "registryType": "npm",
13
14
  "identifier": "burn-mcp-server",
14
- "version": "2.0.2",
15
+ "version": "2.0.6",
15
16
  "transport": {
16
17
  "type": "stdio"
17
18
  },
18
19
  "environmentVariables": [
19
20
  {
20
- "description": "Long-lived MCP access token from Burn App Settings",
21
+ "name": "BURN_MCP_TOKEN",
22
+ "description": "Long-lived MCP token from Burn App → Settings → MCP Server",
21
23
  "isRequired": true,
22
24
  "format": "string",
23
- "isSecret": true,
24
- "name": "BURN_MCP_TOKEN"
25
+ "isSecret": true
25
26
  }
26
27
  ]
27
28
  }
package/src/index.ts CHANGED
@@ -472,7 +472,7 @@ async function handleGetArticleContent(args: { id: string }) {
472
472
  // ---------------------------------------------------------------------------
473
473
 
474
474
  const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud'
475
- const API_KEY = process.env.BURN_API_KEY || 'burn451-2026-secret-key'
475
+ const API_KEY = process.env.BURN_API_KEY
476
476
 
477
477
  /** Detect platform from URL */
478
478
  function detectPlatform(url: string): string {
@@ -523,7 +523,7 @@ async function fetchViaAPI(url: string, platform: string): Promise<{ title?: str
523
523
 
524
524
  const resp = await fetch(`${endpoint}?${params}`, {
525
525
  headers: {
526
- 'x-api-key': API_KEY,
526
+ ...(API_KEY ? { 'x-api-key': API_KEY } : {}),
527
527
  'Accept': 'application/json',
528
528
  },
529
529
  signal: AbortSignal.timeout(30000),
@@ -1076,6 +1076,249 @@ async function handleUpdateCollectionOverview(args: {
1076
1076
  // Register tools
1077
1077
  // ---------------------------------------------------------------------------
1078
1078
 
1079
+ // ---------------------------------------------------------------------------
1080
+ // Watched Sources — RSS/Atom parser (no external deps)
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ interface FeedItem {
1084
+ url: string
1085
+ title: string
1086
+ author: string
1087
+ publishedAt: string
1088
+ }
1089
+
1090
+ function decodeXMLEntities(str: string): string {
1091
+ return str
1092
+ .replace(/&amp;/g, '&')
1093
+ .replace(/&lt;/g, '<')
1094
+ .replace(/&gt;/g, '>')
1095
+ .replace(/&quot;/g, '"')
1096
+ .replace(/&#39;/g, "'")
1097
+ .replace(/&apos;/g, "'")
1098
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
1099
+ }
1100
+
1101
+ function extractXMLValue(block: string, tag: string): string | null {
1102
+ // CDATA
1103
+ const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, 'i')
1104
+ const cdata = block.match(cdataRe)
1105
+ if (cdata) return cdata[1].trim()
1106
+
1107
+ // Atom <link href="..."> self-closing
1108
+ if (tag === 'link') {
1109
+ const href = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*(?:\/>|>)/i)
1110
+ if (href) return href[1].trim()
1111
+ }
1112
+
1113
+ // Normal element content
1114
+ const normalRe = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i')
1115
+ const normal = block.match(normalRe)
1116
+ if (normal) return decodeXMLEntities(normal[1].trim())
1117
+
1118
+ return null
1119
+ }
1120
+
1121
+ function parseRSSFeed(xml: string): FeedItem[] {
1122
+ const items: FeedItem[] = []
1123
+ const itemRe = /<(?:item|entry)(?: [^>]*)?>([\s\S]*?)<\/(?:item|entry)>/gi
1124
+ let m: RegExpExecArray | null
1125
+
1126
+ while ((m = itemRe.exec(xml)) !== null) {
1127
+ const block = m[1]
1128
+ const title = extractXMLValue(block, 'title') || 'Untitled'
1129
+ const rawUrl = extractXMLValue(block, 'link') || extractXMLValue(block, 'id') || ''
1130
+ if (!rawUrl.startsWith('http')) continue
1131
+
1132
+ // Rewrite nitter domains to x.com so parse-x picks them up correctly
1133
+ const url = rawUrl.replace(/^https?:\/\/nitter\.[^/]+/, 'https://x.com')
1134
+
1135
+ const pubStr = extractXMLValue(block, 'pubDate')
1136
+ || extractXMLValue(block, 'published')
1137
+ || extractXMLValue(block, 'updated')
1138
+ || ''
1139
+
1140
+ let publishedAt = new Date().toISOString()
1141
+ try { if (pubStr) publishedAt = new Date(pubStr).toISOString() } catch { /* keep default */ }
1142
+
1143
+ const author = extractXMLValue(block, 'author') || extractXMLValue(block, 'dc:creator') || ''
1144
+ items.push({ url, title, author, publishedAt })
1145
+ }
1146
+
1147
+ return items
1148
+ }
1149
+
1150
+ async function fetchRSSFeed(feedUrl: string): Promise<FeedItem[]> {
1151
+ const resp = await fetch(feedUrl, {
1152
+ signal: AbortSignal.timeout(12000),
1153
+ headers: {
1154
+ // Use browser UA — many RSS hosts (bearblog, Substack) block bot UAs
1155
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
1156
+ Accept: 'application/rss+xml, application/atom+xml, text/xml, */*',
1157
+ },
1158
+ })
1159
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${feedUrl}`)
1160
+ return parseRSSFeed(await resp.text())
1161
+ }
1162
+
1163
+ async function getSourceItems(source: any): Promise<FeedItem[]> {
1164
+ const since = source.last_checked_at ? new Date(source.last_checked_at) : new Date(0)
1165
+
1166
+ switch (source.source_type) {
1167
+ case 'x_user': {
1168
+ // All public nitter instances are currently offline (2026).
1169
+ // X/Twitter removed API access for third-party proxies.
1170
+ // Use fetch_content to add individual tweets manually.
1171
+ throw new Error(`X/Twitter timeline scraping is unavailable — public nitter/RSS proxies are offline. To add @${source.handle} tweets, use fetch_content with individual tweet URLs.`)
1172
+ }
1173
+
1174
+ case 'rss': {
1175
+ const items = await fetchRSSFeed(source.handle)
1176
+ return items.filter(i => new Date(i.publishedAt) > since)
1177
+ }
1178
+
1179
+ case 'youtube': {
1180
+ // Accept channel ID (UCxxx) or full channel URL
1181
+ const channelId = source.handle.match(/UC[A-Za-z0-9_-]{21}[AQgw]/)?.[0] || source.handle
1182
+ const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`
1183
+ const items = await fetchRSSFeed(rssUrl)
1184
+ return items.filter(i => new Date(i.publishedAt) > since)
1185
+ }
1186
+
1187
+ default:
1188
+ return []
1189
+ }
1190
+ }
1191
+
1192
+ // ---------------------------------------------------------------------------
1193
+ // Watched Sources — handlers
1194
+ // ---------------------------------------------------------------------------
1195
+
1196
+ async function handleAddWatchedSource(args: { source_type: string; handle: string; name?: string }) {
1197
+ const { data: { user } } = await supabase.auth.getUser()
1198
+ if (!user) return textResult('Error: Not authenticated')
1199
+
1200
+ const { data: existing } = await supabase
1201
+ .from('watched_sources')
1202
+ .select('id, display_name')
1203
+ .eq('user_id', user.id)
1204
+ .eq('handle', args.handle)
1205
+ .eq('active', true)
1206
+ .maybeSingle()
1207
+
1208
+ if (existing) return textResult(`Already watching "${existing.display_name}"`)
1209
+
1210
+ const { data, error } = await supabase
1211
+ .from('watched_sources')
1212
+ .insert({
1213
+ user_id: user.id,
1214
+ source_type: args.source_type,
1215
+ handle: args.handle,
1216
+ display_name: args.name || args.handle,
1217
+ // null = never checked; first scrape will fetch whatever the feed currently contains
1218
+ })
1219
+ .select()
1220
+ .single()
1221
+
1222
+ if (error) return textResult(`Error: ${error.message}`)
1223
+ return textResult(JSON.stringify({
1224
+ success: true,
1225
+ id: data.id,
1226
+ message: `Now watching "${data.display_name}" (${data.source_type}). Call scrape_watched_sources to fetch new items.`,
1227
+ }, null, 2))
1228
+ }
1229
+
1230
+ async function handleListWatchedSources() {
1231
+ const { data, error } = await supabase
1232
+ .from('watched_sources')
1233
+ .select('id, source_type, handle, display_name, last_checked_at, created_at')
1234
+ .eq('active', true)
1235
+ .order('created_at', { ascending: false })
1236
+
1237
+ if (error) return textResult(`Error: ${error.message}`)
1238
+ if (!data || data.length === 0) return textResult('No watched sources yet. Use add_watched_source to add one.')
1239
+ return textResult(JSON.stringify(data, null, 2))
1240
+ }
1241
+
1242
+ async function handleRemoveWatchedSource(args: { id: string }) {
1243
+ const { error } = await supabase
1244
+ .from('watched_sources')
1245
+ .update({ active: false })
1246
+ .eq('id', args.id)
1247
+
1248
+ if (error) return textResult(`Error: ${error.message}`)
1249
+ return textResult('Watched source removed.')
1250
+ }
1251
+
1252
+ async function handleScrapeWatchedSources(args: { source_id?: string }) {
1253
+ const { data: { user } } = await supabase.auth.getUser()
1254
+ if (!user) return textResult('Error: Not authenticated')
1255
+
1256
+ let query = supabase
1257
+ .from('watched_sources')
1258
+ .select('*')
1259
+ .eq('active', true)
1260
+ .eq('user_id', user.id)
1261
+
1262
+ if (args.source_id) query = query.eq('id', args.source_id)
1263
+
1264
+ const { data: sources, error } = await query
1265
+ if (error) return textResult(`Error: ${error.message}`)
1266
+ if (!sources || sources.length === 0) {
1267
+ return textResult('No active watched sources. Use add_watched_source to add one.')
1268
+ }
1269
+
1270
+ const countdownExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
1271
+ const results: any[] = []
1272
+
1273
+ for (const source of sources) {
1274
+ let added = 0
1275
+ let skipped = 0
1276
+ try {
1277
+ const items = await getSourceItems(source)
1278
+
1279
+ for (const item of items) {
1280
+ const { data: dupe } = await supabase
1281
+ .from('bookmarks')
1282
+ .select('id')
1283
+ .eq('url', item.url)
1284
+ .maybeSingle()
1285
+
1286
+ if (dupe) { skipped++; continue }
1287
+
1288
+ const { error: insertErr } = await supabase
1289
+ .from('bookmarks')
1290
+ .insert({
1291
+ user_id: user.id,
1292
+ url: item.url,
1293
+ title: item.title,
1294
+ platform: detectPlatform(item.url),
1295
+ status: 'active',
1296
+ countdown_expires_at: countdownExpiresAt,
1297
+ content_metadata: {
1298
+ author: item.author || source.display_name,
1299
+ watched_source_id: source.id,
1300
+ watched_source_name: source.display_name,
1301
+ },
1302
+ })
1303
+
1304
+ if (!insertErr) added++
1305
+ }
1306
+
1307
+ await supabase
1308
+ .from('watched_sources')
1309
+ .update({ last_checked_at: new Date().toISOString() })
1310
+ .eq('id', source.id)
1311
+
1312
+ results.push({ source: source.display_name, type: source.source_type, added, skipped })
1313
+ } catch (err: any) {
1314
+ results.push({ source: source.display_name, type: source.source_type, error: err.message })
1315
+ }
1316
+ }
1317
+
1318
+ const totalAdded = results.reduce((s, r) => s + (r.added || 0), 0)
1319
+ return textResult(JSON.stringify({ totalAdded, sources: results }, null, 2))
1320
+ }
1321
+
1079
1322
  // @ts-expect-error — MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
1080
1323
  server.tool(
1081
1324
  'search_vault',
@@ -1294,6 +1537,43 @@ server.tool(
1294
1537
  rateLimited(handleUpdateCollectionOverview)
1295
1538
  )
1296
1539
 
1540
+ // ---------------------------------------------------------------------------
1541
+ // Watched Sources tools
1542
+ // ---------------------------------------------------------------------------
1543
+
1544
+ // @ts-expect-error — MCP SDK TS2589
1545
+ server.tool(
1546
+ 'add_watched_source',
1547
+ 'Watch an X user, RSS feed, or YouTube channel — new posts auto-appear in Burn Flame on each scrape.',
1548
+ {
1549
+ source_type: z.enum(['x_user', 'rss', 'youtube']).describe('x_user = Twitter/X handle | rss = any RSS/Atom feed URL | youtube = YouTube channel ID'),
1550
+ handle: z.string().describe('x_user: username without @ (e.g. "karpathy") | rss: full feed URL | youtube: channel ID starting with UC'),
1551
+ name: z.string().optional().describe('Human-friendly display name (defaults to handle)'),
1552
+ },
1553
+ rateLimited(handleAddWatchedSource)
1554
+ )
1555
+
1556
+ server.tool(
1557
+ 'list_watched_sources',
1558
+ 'List all active watched sources (X users, RSS feeds, YouTube channels).',
1559
+ {},
1560
+ rateLimited(handleListWatchedSources)
1561
+ )
1562
+
1563
+ server.tool(
1564
+ 'remove_watched_source',
1565
+ 'Stop watching a source. Use list_watched_sources to find the source ID.',
1566
+ { id: z.string().describe('Watched source UUID from list_watched_sources') },
1567
+ rateLimited(handleRemoveWatchedSource)
1568
+ )
1569
+
1570
+ server.tool(
1571
+ 'scrape_watched_sources',
1572
+ 'Fetch new content from all watched sources (or one specific source) and add new items to Burn Flame. Call this on a schedule or on demand.',
1573
+ { source_id: z.string().optional().describe('Scrape only this source ID — omit to scrape all active sources') },
1574
+ rateLimited(handleScrapeWatchedSources)
1575
+ )
1576
+
1297
1577
  // ---------------------------------------------------------------------------
1298
1578
  // Resource: burn://vault/bookmarks
1299
1579
  // ---------------------------------------------------------------------------
package/smithery.yaml DELETED
@@ -1,17 +0,0 @@
1
- # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2
-
3
- startCommand:
4
- type: stdio
5
-
6
- configSchema:
7
- type: object
8
- required:
9
- - burnMcpToken
10
- properties:
11
- burnMcpToken:
12
- type: string
13
- description: Long-lived MCP access token from Burn App Settings page.
14
-
15
- commandFunction:
16
- |-
17
- (config) => ({ command: 'npx', args: ['-y', 'burn-mcp-server'], env: { BURN_MCP_TOKEN: config.burnMcpToken } })