feedkeeper 0.35.3 → 0.35.5
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/package.json +3 -3
- package/server/dist/api/auth.js +1 -1
- package/server/dist/api/feeds.js +1 -1
- package/server/dist/api/folders.js +11 -8
- package/server/dist/api/items.js +3 -3
- package/server/dist/api/onboarding.js +2 -1
- package/server/dist/api/v1/resources.js +14 -19
- package/server/dist/api/v1/serialize.js +1 -1
- package/server/dist/auth/middleware.js +2 -2
- package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
- package/server/dist/feeds/archive.js +3 -2
- package/server/dist/feeds/articleContent.js +3 -3
- package/server/dist/feeds/content/index.js +2 -1
- package/server/dist/feeds/content/noise/lazyImages.js +23 -0
- package/server/dist/feeds/content/noise/videoPages.js +67 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
- package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
- package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
- package/server/dist/feeds/contentNoise.js +21 -5
- package/server/dist/feeds/extractor.js +2 -1
- package/server/dist/feeds/feedIcon.js +4 -2
- package/server/dist/feeds/imageSources.js +69 -0
- package/server/dist/feeds/inbox.js +4 -9
- package/server/dist/feeds/opml.js +122 -27
- package/server/dist/feeds/repository.js +19 -7
- package/server/dist/mcp/items.js +1 -1
- package/server/dist/mcp/server.js +17 -3
- package/server/dist/server.js +1 -1
- package/server/dist/sync/changeLog.js +1 -0
- package/server/package.json +1 -1
- package/web/dist/assets/index-CilgZLF5.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-CYi9R_Ds.js +0 -32
|
@@ -119,18 +119,13 @@ export async function saveToInbox(userId, input) {
|
|
|
119
119
|
return { itemId, item, hasNote };
|
|
120
120
|
}
|
|
121
121
|
export function deleteInboxItem(userId, itemId) {
|
|
122
|
-
const inbox =
|
|
122
|
+
const inbox = findFeedByUrl(`feedkeeper://inbox/${userId}`);
|
|
123
123
|
const item = findItemById(itemId);
|
|
124
|
-
if (!item || item.feed_id !== inbox.id) {
|
|
124
|
+
if (!inbox || !item || item.feed_id !== inbox.id) {
|
|
125
125
|
return false;
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
db.prepare("DELETE FROM item_reads WHERE item_id = ?").run(itemId);
|
|
130
|
-
db.prepare("DELETE FROM item_progress WHERE item_id = ?").run(itemId);
|
|
131
|
-
db.prepare("DELETE FROM item_notes WHERE item_id = ?").run(itemId);
|
|
132
|
-
db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
|
|
133
|
-
})();
|
|
127
|
+
// Cascades run after the parent disappears, so state/note triggers do not announce stale updates.
|
|
128
|
+
db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
|
|
134
129
|
pruneArchive();
|
|
135
130
|
return true;
|
|
136
131
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { XMLParser } from "fast-xml-parser";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { config } from "../config.js";
|
|
4
|
-
import { listSubscriptionsForUser, findFeedByUrl, createFeed, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
|
|
5
|
-
import { SsrfBlockedError } from "./ssrfGuard.js";
|
|
4
|
+
import { listSubscriptionsForUser, findFeedByUrl, createFeed, seedFeedMetadata, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
|
|
5
|
+
import { SsrfBlockedError, normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
6
6
|
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
7
7
|
import { requestFeedPoll } from "./poller.js";
|
|
8
|
-
import { decodeEntities } from "./text.js";
|
|
8
|
+
import { decodeEntities, plainTitle } from "./text.js";
|
|
9
|
+
const MAX_FOLDER_NAME_LENGTH = 100;
|
|
10
|
+
const MAX_FEED_LABEL_LENGTH = 200;
|
|
9
11
|
function escapeXml(unsafe) {
|
|
10
12
|
return unsafe.replace(/[<>&'"]/g, (char) => {
|
|
11
13
|
switch (char) {
|
|
@@ -26,9 +28,10 @@ function escapeXml(unsafe) {
|
|
|
26
28
|
}
|
|
27
29
|
function feedToOutlineXml(s, indent = " ") {
|
|
28
30
|
const text = escapeXml(s.label || s.title || s.url);
|
|
31
|
+
const title = escapeXml(s.title || s.label || s.url);
|
|
29
32
|
const xmlUrl = escapeXml(s.url);
|
|
30
33
|
const htmlUrl = s.site_url ? ` htmlUrl="${escapeXml(s.site_url)}"` : "";
|
|
31
|
-
return `${indent}<outline text="${text}" title="${
|
|
34
|
+
return `${indent}<outline text="${text}" title="${title}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Generates standard OPML 2.0 XML representation of all subscribed feeds for a user.
|
|
@@ -74,7 +77,26 @@ ${outlines}
|
|
|
74
77
|
`;
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
|
-
*
|
|
80
|
+
* Reads an attribute from a parsed XML node case-insensitively in priority order.
|
|
81
|
+
*/
|
|
82
|
+
function getAttr(record, ...names) {
|
|
83
|
+
const entries = Object.entries(record);
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const target = `@_${name.toLowerCase()}`;
|
|
86
|
+
for (const [key, value] of entries) {
|
|
87
|
+
if (key.toLowerCase() === target) {
|
|
88
|
+
if (typeof value === "string")
|
|
89
|
+
return value;
|
|
90
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
91
|
+
return String(value);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Recursively extracts outline elements containing a feed URL from parsed OPML.
|
|
99
|
+
* Nested subfolders are flattened to the top level so each subfolder becomes its own folder.
|
|
78
100
|
*/
|
|
79
101
|
function extractOutlines(node, items = [], currentFolder) {
|
|
80
102
|
if (!node || typeof node !== "object")
|
|
@@ -86,19 +108,30 @@ function extractOutlines(node, items = [], currentFolder) {
|
|
|
86
108
|
return items;
|
|
87
109
|
}
|
|
88
110
|
const record = node;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
const hasChildren = Boolean(record.outline);
|
|
112
|
+
// Leaf outlines without xmlUrl may use url="..." in older OPML 1.0 exports.
|
|
113
|
+
const rawXmlUrl = getAttr(record, "xmlUrl") ?? (!hasChildren ? getAttr(record, "url") : undefined);
|
|
114
|
+
const rawText = getAttr(record, "text");
|
|
115
|
+
const rawTitle = getAttr(record, "title");
|
|
116
|
+
const rawHtmlUrl = getAttr(record, "htmlUrl");
|
|
117
|
+
const label = plainTitle(rawText ?? rawTitle)?.slice(0, MAX_FEED_LABEL_LENGTH);
|
|
118
|
+
const title = plainTitle(rawTitle ?? rawText)?.slice(0, MAX_FEED_LABEL_LENGTH);
|
|
119
|
+
const siteUrl = rawHtmlUrl?.trim() ? decodeEntities(rawHtmlUrl.trim()) : undefined;
|
|
120
|
+
let childFolder = currentFolder;
|
|
121
|
+
if (rawXmlUrl && rawXmlUrl.trim()) {
|
|
122
|
+
const url = decodeEntities(rawXmlUrl.trim());
|
|
123
|
+
const item = { url, label, folder: currentFolder };
|
|
124
|
+
if (title && title !== label)
|
|
125
|
+
item.title = title;
|
|
126
|
+
if (siteUrl)
|
|
127
|
+
item.siteUrl = siteUrl;
|
|
128
|
+
items.push(item);
|
|
96
129
|
}
|
|
97
|
-
else if (
|
|
98
|
-
|
|
130
|
+
else if (label && hasChildren) {
|
|
131
|
+
childFolder = label.slice(0, MAX_FOLDER_NAME_LENGTH);
|
|
99
132
|
}
|
|
100
|
-
if (
|
|
101
|
-
extractOutlines(record.outline, items,
|
|
133
|
+
if (hasChildren) {
|
|
134
|
+
extractOutlines(record.outline, items, childFolder);
|
|
102
135
|
}
|
|
103
136
|
return items;
|
|
104
137
|
}
|
|
@@ -107,22 +140,77 @@ export const MAX_OPML_FEEDS = 2000;
|
|
|
107
140
|
* Parses an OPML document string and returns a list of feed URLs and their labels.
|
|
108
141
|
*/
|
|
109
142
|
export function parseOpml(xmlContent) {
|
|
143
|
+
const cleanedXml = xmlContent.replace(/^\uFEFF/, "").trim();
|
|
144
|
+
if (!cleanedXml) {
|
|
145
|
+
throw new Error("invalid_opml_format");
|
|
146
|
+
}
|
|
110
147
|
const parser = new XMLParser({
|
|
111
148
|
ignoreAttributes: false,
|
|
112
149
|
attributeNamePrefix: "@_",
|
|
113
150
|
trimValues: true,
|
|
114
151
|
});
|
|
115
|
-
const parsed = parser.parse(
|
|
116
|
-
if (!parsed ||
|
|
152
|
+
const parsed = parser.parse(cleanedXml);
|
|
153
|
+
if (!parsed || typeof parsed.opml !== "object" || parsed.opml === null || !("body" in parsed.opml)) {
|
|
117
154
|
throw new Error("invalid_opml_format");
|
|
118
155
|
}
|
|
119
|
-
const
|
|
156
|
+
const body = parsed.opml.body;
|
|
157
|
+
const outlineRoot = body && typeof body === "object" ? body.outline : undefined;
|
|
158
|
+
const feeds = extractOutlines(outlineRoot);
|
|
120
159
|
if (feeds.length > MAX_OPML_FEEDS) {
|
|
121
160
|
throw new Error("too_many_opml_feeds");
|
|
122
161
|
}
|
|
123
162
|
return feeds;
|
|
124
163
|
}
|
|
125
164
|
const urlSchema = z.string().url();
|
|
165
|
+
function normalizeOpmlUrl(rawUrl) {
|
|
166
|
+
const trimmed = rawUrl.trim();
|
|
167
|
+
if (/^feeds?:https?:\/\//i.test(trimmed)) {
|
|
168
|
+
return trimmed.replace(/^feeds?:/i, "");
|
|
169
|
+
}
|
|
170
|
+
if (/^feeds?:\/\//i.test(trimmed)) {
|
|
171
|
+
const rest = trimmed.replace(/^feeds?:\/\//i, "");
|
|
172
|
+
return /^https?:\/\//i.test(rest) ? rest : `https://${rest}`;
|
|
173
|
+
}
|
|
174
|
+
if (trimmed.startsWith("//"))
|
|
175
|
+
return `https:${trimmed}`;
|
|
176
|
+
return trimmed;
|
|
177
|
+
}
|
|
178
|
+
function sanitizeHttpUrl(raw) {
|
|
179
|
+
if (!raw)
|
|
180
|
+
return null;
|
|
181
|
+
const trimmed = raw.trim();
|
|
182
|
+
if (!trimmed)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = new URL(trimmed.startsWith("//") ? `https:${trimmed}` : trimmed);
|
|
186
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
187
|
+
return null;
|
|
188
|
+
return parsed.toString();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isUrlFallbackLabel(label, rawUrl, normalizedUrl) {
|
|
195
|
+
if (!label)
|
|
196
|
+
return true;
|
|
197
|
+
const trimmed = label.trim();
|
|
198
|
+
if (!trimmed)
|
|
199
|
+
return true;
|
|
200
|
+
if (trimmed === rawUrl || trimmed === normalizedUrl)
|
|
201
|
+
return true;
|
|
202
|
+
try {
|
|
203
|
+
return new URL(normalizeOpmlUrl(trimmed)).toString() === normalizedUrl;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(normalizeUrlCandidate(trimmed)).toString() === normalizedUrl;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
126
214
|
/**
|
|
127
215
|
* Imports feeds from an OPML document string for the given user.
|
|
128
216
|
*/
|
|
@@ -139,7 +227,8 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
139
227
|
for (const item of feedItems) {
|
|
140
228
|
if (!item.url)
|
|
141
229
|
continue;
|
|
142
|
-
const
|
|
230
|
+
const candidateUrl = normalizeOpmlUrl(item.url);
|
|
231
|
+
const urlCheck = urlSchema.safeParse(candidateUrl);
|
|
143
232
|
if (!urlCheck.success) {
|
|
144
233
|
result.failed++;
|
|
145
234
|
result.errors.push({ url: item.url, reason: "invalid_url_format" });
|
|
@@ -147,7 +236,7 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
147
236
|
}
|
|
148
237
|
let normalizedUrl;
|
|
149
238
|
try {
|
|
150
|
-
const validated = await assertPublicFeedUrl(
|
|
239
|
+
const validated = await assertPublicFeedUrl(candidateUrl);
|
|
151
240
|
normalizedUrl = validated.toString();
|
|
152
241
|
}
|
|
153
242
|
catch (error) {
|
|
@@ -161,22 +250,28 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
161
250
|
if (!feed) {
|
|
162
251
|
feed = createFeed(normalizedUrl, Math.max(config.minPollIntervalMinutes, 15));
|
|
163
252
|
}
|
|
253
|
+
const rawTitle = item.title ?? item.label;
|
|
254
|
+
const seedTitle = isUrlFallbackLabel(rawTitle, item.url, normalizedUrl) ? null : (rawTitle ?? null);
|
|
255
|
+
const seedSiteUrl = sanitizeHttpUrl(item.siteUrl);
|
|
256
|
+
seedFeedMetadata(feed.id, { title: seedTitle, siteUrl: seedSiteUrl });
|
|
164
257
|
if (isUserSubscribed(userId, feed.id)) {
|
|
165
258
|
result.skipped++;
|
|
166
259
|
continue;
|
|
167
260
|
}
|
|
168
261
|
let folderId = null;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
262
|
+
const folderName = item.folder?.trim().slice(0, MAX_FOLDER_NAME_LENGTH);
|
|
263
|
+
if (folderName) {
|
|
264
|
+
if (folderCache.has(folderName)) {
|
|
265
|
+
folderId = folderCache.get(folderName);
|
|
172
266
|
}
|
|
173
267
|
else {
|
|
174
|
-
const folder = createFolder(userId,
|
|
268
|
+
const folder = createFolder(userId, folderName);
|
|
175
269
|
folderId = folder.id;
|
|
176
|
-
folderCache.set(
|
|
270
|
+
folderCache.set(folderName, folder.id);
|
|
177
271
|
}
|
|
178
272
|
}
|
|
179
|
-
|
|
273
|
+
const customLabel = isUrlFallbackLabel(item.label, item.url, normalizedUrl) ? null : (item.label ?? null);
|
|
274
|
+
subscribe(userId, feed.id, customLabel, folderId);
|
|
180
275
|
result.imported++;
|
|
181
276
|
feedsToPoll.push(feed);
|
|
182
277
|
}
|
|
@@ -16,6 +16,14 @@ export function createFeed(url, pollIntervalMinutes) {
|
|
|
16
16
|
.run(url, pollIntervalMinutes);
|
|
17
17
|
return findFeedById(Number(result.lastInsertRowid));
|
|
18
18
|
}
|
|
19
|
+
export function seedFeedMetadata(feedId, data) {
|
|
20
|
+
if (!data.title && !data.siteUrl)
|
|
21
|
+
return;
|
|
22
|
+
db.prepare(`UPDATE feeds SET
|
|
23
|
+
title = COALESCE(NULLIF(TRIM(title), ''), ?),
|
|
24
|
+
site_url = COALESCE(NULLIF(TRIM(site_url), ''), ?)
|
|
25
|
+
WHERE id = ?`).run(data.title?.trim() || null, data.siteUrl?.trim() || null, feedId);
|
|
26
|
+
}
|
|
19
27
|
export function subscribe(userId, feedId, label, folderId) {
|
|
20
28
|
const maxPosRow = db
|
|
21
29
|
.prepare("SELECT MAX(position) AS max_pos FROM subscriptions WHERE user_id = ?")
|
|
@@ -170,7 +178,7 @@ export function upsertItems(feedId, items) {
|
|
|
170
178
|
refreshLead(Number(result.lastInsertRowid));
|
|
171
179
|
}
|
|
172
180
|
else {
|
|
173
|
-
const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
181
|
+
const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, published_at, author, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
174
182
|
const next = row.contentHtml;
|
|
175
183
|
// Some publishers replace complete feed bodies with short teasers on a later poll.
|
|
176
184
|
const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
|
|
@@ -185,9 +193,9 @@ export function upsertItems(feedId, items) {
|
|
|
185
193
|
(row.link !== undefined && row.link !== previous.link) ||
|
|
186
194
|
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
|
|
187
195
|
(retained !== null && retained !== previous.content_html) ||
|
|
188
|
-
(row.publishedAt !== undefined && row.publishedAt !== null) ||
|
|
196
|
+
(row.publishedAt !== undefined && row.publishedAt !== null && row.publishedAt !== previous.published_at) ||
|
|
189
197
|
(nextImage !== previous.image_url) ||
|
|
190
|
-
(row.author !== undefined);
|
|
198
|
+
(row.author !== undefined && row.author !== null && row.author !== previous.author);
|
|
191
199
|
if (changed) {
|
|
192
200
|
update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
|
|
193
201
|
const leadChanged = (retained !== null && retained !== previous.content_html) ||
|
|
@@ -210,15 +218,17 @@ export function upsertItems(feedId, items) {
|
|
|
210
218
|
return insertMany(items);
|
|
211
219
|
}
|
|
212
220
|
export function listItemsForUser(userId, opts = {}) {
|
|
213
|
-
// Saved articles stay visible
|
|
221
|
+
// Saved and annotated articles stay visible after unsubscribing.
|
|
214
222
|
const conditions = [];
|
|
223
|
+
const params = [];
|
|
215
224
|
if (opts.bookmarkedOnly) {
|
|
216
225
|
conditions.push("b.item_id IS NOT NULL");
|
|
217
226
|
}
|
|
218
227
|
else {
|
|
219
|
-
conditions.push(
|
|
228
|
+
conditions.push(`(s.id IS NOT NULL OR b.item_id IS NOT NULL OR
|
|
229
|
+
EXISTS (SELECT 1 FROM item_notes access_note WHERE access_note.item_id = i.id AND access_note.user_id = ?))`);
|
|
230
|
+
params.push(userId);
|
|
220
231
|
}
|
|
221
|
-
const params = [];
|
|
222
232
|
// Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
|
|
223
233
|
conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
|
|
224
234
|
EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
|
|
@@ -410,7 +420,9 @@ export function findFolderById(userId, id) {
|
|
|
410
420
|
}
|
|
411
421
|
export function createFolder(userId, name, iconSymbol) {
|
|
412
422
|
const trimmed = name.trim();
|
|
413
|
-
db.prepare(
|
|
423
|
+
db.prepare(`INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?)
|
|
424
|
+
ON CONFLICT (user_id, name) DO UPDATE SET icon_symbol = excluded.icon_symbol
|
|
425
|
+
WHERE ? AND folders.icon_symbol IS NOT excluded.icon_symbol`).run(userId, trimmed, iconSymbol ?? null, Number(iconSymbol !== undefined));
|
|
414
426
|
return findFolderByName(userId, trimmed);
|
|
415
427
|
}
|
|
416
428
|
export function updateFolder(userId, id, updates) {
|
package/server/dist/mcp/items.js
CHANGED
|
@@ -51,7 +51,7 @@ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, off
|
|
|
51
51
|
return null;
|
|
52
52
|
if (revision !== undefined && revision !== item.content_revision)
|
|
53
53
|
throw new Error("content_revision_conflict");
|
|
54
|
-
const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author);
|
|
54
|
+
const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author, item.link);
|
|
55
55
|
const body = format === "text" ? sourceText(item) : content ?? "";
|
|
56
56
|
let end = Math.min(offset + Math.min(maxChars, MAX_CONTENT_LENGTH), body.length);
|
|
57
57
|
if (end < body.length && /[\uD800-\uDBFF]/.test(body[end - 1]))
|
|
@@ -42,7 +42,11 @@ function selectedItemIds({ itemId, itemIds }) {
|
|
|
42
42
|
// instance is created per request (see mcp/http.ts) so tool handlers can
|
|
43
43
|
// safely close over `userId` without leaking data between users.
|
|
44
44
|
export function createMcpServerForUser(userId, scope) {
|
|
45
|
-
const server = new McpServer({ name: "feedkeeper", version: APP_VERSION }
|
|
45
|
+
const server = new McpServer({ name: "feedkeeper", version: APP_VERSION }, {
|
|
46
|
+
instructions: "FeedKeeper is a personal RSS reader, article archiver, and news curation service. " +
|
|
47
|
+
"Use it to read, search, and triage RSS/Atom feeds, fetch full article text, manage reading lists, " +
|
|
48
|
+
"and read or publish curated newspaper editions.",
|
|
49
|
+
});
|
|
46
50
|
server.registerTool("list_feeds", {
|
|
47
51
|
title: "List subscribed feeds",
|
|
48
52
|
description: "Lists all RSS/Atom feeds the current user is subscribed to, including unread counts and polling health.",
|
|
@@ -64,6 +68,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
64
68
|
folderId: z.number().int().positive().optional().describe("Optional folder/category ID"),
|
|
65
69
|
},
|
|
66
70
|
}, async ({ url, label, folderId }) => {
|
|
71
|
+
if (!hasUserCapability(userId, "sync"))
|
|
72
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
67
73
|
try {
|
|
68
74
|
const subscription = await subscribeToFeed(userId, url, label ?? null, folderId ?? null);
|
|
69
75
|
return textResult(subscription);
|
|
@@ -90,6 +96,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
90
96
|
url: mcpUrlSchema.describe("The website URL to discover feeds from"),
|
|
91
97
|
},
|
|
92
98
|
}, async ({ url }) => {
|
|
99
|
+
if (!hasUserCapability(userId, "sync"))
|
|
100
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
93
101
|
try {
|
|
94
102
|
const feeds = await discoverFeeds(url);
|
|
95
103
|
return textResult({ feeds });
|
|
@@ -116,8 +124,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
116
124
|
});
|
|
117
125
|
if (scope === "write")
|
|
118
126
|
server.registerTool("save_to_inbox", {
|
|
119
|
-
title: "Save article or
|
|
120
|
-
description: "Saves a web article URL, raw HTML, text clipping, or
|
|
127
|
+
title: "Save article or clipping to Inbox",
|
|
128
|
+
description: "Saves a web article URL, raw HTML, text clipping, or note directly into the user's Universal Inbox.",
|
|
121
129
|
inputSchema: {
|
|
122
130
|
url: mcpUrlSchema.optional().describe("Web page URL to fetch, extract, and save"),
|
|
123
131
|
title: z.string().max(500).optional().describe("Optional custom title"),
|
|
@@ -189,6 +197,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
189
197
|
badge: z.boolean().optional().describe("Count this feed's unread articles in the number on the app icon"),
|
|
190
198
|
},
|
|
191
199
|
}, async ({ feedId, url, label, pollIntervalMinutes, fullText, notify, badge }) => {
|
|
200
|
+
if (url && !hasUserCapability(userId, "sync"))
|
|
201
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
192
202
|
try {
|
|
193
203
|
let targetFeedId = feedId;
|
|
194
204
|
let previousItemsKept;
|
|
@@ -425,6 +435,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
425
435
|
opml: z.string().min(1).describe("The OPML XML content to import"),
|
|
426
436
|
},
|
|
427
437
|
}, async ({ opml }) => {
|
|
438
|
+
if (!hasUserCapability(userId, "sync"))
|
|
439
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
428
440
|
try {
|
|
429
441
|
const result = await importOpmlFeeds(userId, opml);
|
|
430
442
|
return textResult(result);
|
|
@@ -441,6 +453,8 @@ export function createMcpServerForUser(userId, scope) {
|
|
|
441
453
|
feedId: z.number().int().positive().describe("The ID of the feed to refresh"),
|
|
442
454
|
},
|
|
443
455
|
}, async ({ feedId }) => {
|
|
456
|
+
if (!hasUserCapability(userId, "sync"))
|
|
457
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
444
458
|
const feed = findFeedById(feedId);
|
|
445
459
|
if (!feed || !isUserSubscribed(userId, feedId)) {
|
|
446
460
|
return errorResult("Feed not found or not subscribed");
|
package/server/dist/server.js
CHANGED
|
@@ -62,7 +62,7 @@ app.use(cors({
|
|
|
62
62
|
credentials: true,
|
|
63
63
|
}));
|
|
64
64
|
app.use(express.json({ limit: "1mb" }));
|
|
65
|
-
app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
|
|
65
|
+
app.use(express.text({ type: ["application/xml", "text/xml", "text/x-opml", "application/opml+xml", "text/plain"], limit: "5mb" }));
|
|
66
66
|
app.use(cookieSession({
|
|
67
67
|
name: "feedkeeper.sid",
|
|
68
68
|
secret: config.sessionSecret,
|
|
@@ -20,6 +20,7 @@ export function serializeSubscription(subscription) {
|
|
|
20
20
|
notify: Boolean(subscription.notify),
|
|
21
21
|
badge: Boolean(subscription.badge),
|
|
22
22
|
isInbox: Boolean(subscription.is_system_inbox),
|
|
23
|
+
lastError: subscription.last_error,
|
|
23
24
|
iconHash: subscription.icon_hash ?? null,
|
|
24
25
|
iconUrl: subscription.icon_hash
|
|
25
26
|
? `/api/v1/subscriptions/${subscription.id}/icon?v=${subscription.icon_hash}`
|