feedkeeper 0.0.0-stage → 0.23.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/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +167 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +21 -0
- package/server/dist/auth/security.js +126 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/dates.js +21 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +223 -0
- package/server/dist/feeds/repository.js +687 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +370 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { listItemsForUser, listSubscriptionsForUser, markItemRead, markItemUnread, markAllRead, findFeedById, isUserSubscribed, bookmarkItem, unbookmarkItem, listMutedKeywords, addMutedKeyword, removeMutedKeyword, listFoldersForUser, findFolderById, createFolder, updateFolder, deleteFolder, updateSubscriptionFolder, applyToItems, } from "../feeds/repository.js";
|
|
4
|
+
import { getItemForMcp, listItemsPage } from "./items.js";
|
|
5
|
+
import { buildDigest, buildOverview, truncate } from "./digest.js";
|
|
6
|
+
import { registerPromptsAndResources } from "./promptsAndResources.js";
|
|
7
|
+
import { subscribeToFeed, unsubscribeFromFeed, updateFeedSettings, changeFeedUrl, FeedError, MultipleFeedsFoundError } from "../feeds/service.js";
|
|
8
|
+
import { saveToInbox } from "../feeds/inbox.js";
|
|
9
|
+
import { loadFullText } from "../feeds/fullText.js";
|
|
10
|
+
import { pruneArchive, scheduleArchive } from "../feeds/archive.js";
|
|
11
|
+
import { SsrfBlockedError, normalizeUrlCandidate } from "../feeds/ssrfGuard.js";
|
|
12
|
+
import { generateOpml, importOpmlFeeds } from "../feeds/opml.js";
|
|
13
|
+
import { pollFeed } from "../feeds/poller.js";
|
|
14
|
+
import { discoverFeeds } from "../feeds/discovery.js";
|
|
15
|
+
import { findUserById } from "../auth/users.js";
|
|
16
|
+
import { getDatabaseStats, runCleanup } from "../feeds/cleanup.js";
|
|
17
|
+
import { APP_VERSION } from "../version.js";
|
|
18
|
+
import { dismissEdition, editionCandidates, generateEdition, generateEditionSchema, getEditionState, publishCuratedEdition, publishEditionFields } from "../native/editions.js";
|
|
19
|
+
import { getNativePreferences, patchNativePreferences, preferencePatchSchema } from "../native/preferences.js";
|
|
20
|
+
import { NativeError } from "../native/requests.js";
|
|
21
|
+
import { capabilityDenial, hasUserCapability } from "../auth/capabilities.js";
|
|
22
|
+
function textResult(payload) {
|
|
23
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
24
|
+
}
|
|
25
|
+
function errorResult(message) {
|
|
26
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
27
|
+
}
|
|
28
|
+
const dateInput = z.union([z.string().datetime({ offset: true }), z.string().date()]);
|
|
29
|
+
const publishedRange = {
|
|
30
|
+
since: dateInput.optional().describe("Only items published at or after this ISO date or date-time"),
|
|
31
|
+
until: dateInput.optional().describe("Only items published at or before this ISO date or date-time"),
|
|
32
|
+
};
|
|
33
|
+
// Item actions accept one ID or a batch, so clients can triage many articles in one call.
|
|
34
|
+
const itemSelection = {
|
|
35
|
+
itemId: z.number().int().positive().optional().describe("A single item ID"),
|
|
36
|
+
itemIds: z.array(z.number().int().positive()).min(1).max(200).optional().describe("Up to 200 item IDs"),
|
|
37
|
+
};
|
|
38
|
+
function selectedItemIds({ itemId, itemIds }) {
|
|
39
|
+
return [...(itemIds ?? []), ...(itemId ? [itemId] : [])];
|
|
40
|
+
}
|
|
41
|
+
// Builds a fresh MCP server scoped to a single authenticated user. A new
|
|
42
|
+
// instance is created per request (see mcp/http.ts) so tool handlers can
|
|
43
|
+
// safely close over `userId` without leaking data between users.
|
|
44
|
+
export function createMcpServerForUser(userId, scope) {
|
|
45
|
+
const server = new McpServer({ name: "feedkeeper", version: APP_VERSION });
|
|
46
|
+
server.registerTool("list_feeds", {
|
|
47
|
+
title: "List subscribed feeds",
|
|
48
|
+
description: "Lists all RSS/Atom feeds the current user is subscribed to, including unread counts and polling health.",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
onlyWithErrors: z.boolean().optional().describe("Only return feeds whose last poll failed"),
|
|
51
|
+
},
|
|
52
|
+
}, async ({ onlyWithErrors }) => {
|
|
53
|
+
const feeds = listSubscriptionsForUser(userId);
|
|
54
|
+
return textResult(onlyWithErrors ? feeds.filter((feed) => feed.last_error || feed.consecutive_errors > 0) : feeds);
|
|
55
|
+
});
|
|
56
|
+
const mcpUrlSchema = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
|
|
57
|
+
if (scope === "write")
|
|
58
|
+
server.registerTool("subscribe_feed", {
|
|
59
|
+
title: "Subscribe to a feed",
|
|
60
|
+
description: "Subscribes the current user to an RSS/Atom feed URL or website URL (auto-discovering the feed).",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
url: mcpUrlSchema.describe("The feed URL or website URL"),
|
|
63
|
+
label: z.string().max(200).optional().describe("Optional display name for this feed"),
|
|
64
|
+
folderId: z.number().int().positive().optional().describe("Optional folder/category ID"),
|
|
65
|
+
},
|
|
66
|
+
}, async ({ url, label, folderId }) => {
|
|
67
|
+
try {
|
|
68
|
+
const subscription = await subscribeToFeed(userId, url, label ?? null, folderId ?? null);
|
|
69
|
+
return textResult(subscription);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof SsrfBlockedError)
|
|
73
|
+
return errorResult(error.message);
|
|
74
|
+
if (error instanceof MultipleFeedsFoundError) {
|
|
75
|
+
return textResult({
|
|
76
|
+
error: "multiple_feeds_found",
|
|
77
|
+
message: "Multiple feeds found. Please specify one of the following feed URLs:",
|
|
78
|
+
feeds: error.feeds,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (error instanceof FeedError)
|
|
82
|
+
return errorResult(error.message);
|
|
83
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
server.registerTool("discover_feeds", {
|
|
87
|
+
title: "Discover feeds on a website",
|
|
88
|
+
description: "Inspects a website URL and discovers available RSS/Atom feed links.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
url: mcpUrlSchema.describe("The website URL to discover feeds from"),
|
|
91
|
+
},
|
|
92
|
+
}, async ({ url }) => {
|
|
93
|
+
try {
|
|
94
|
+
const feeds = await discoverFeeds(url);
|
|
95
|
+
return textResult({ feeds });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof SsrfBlockedError)
|
|
99
|
+
return errorResult(error.message);
|
|
100
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (scope === "write")
|
|
104
|
+
server.registerTool("unsubscribe_feed", {
|
|
105
|
+
title: "Unsubscribe from a feed",
|
|
106
|
+
description: "Removes a feed subscription for the current user.",
|
|
107
|
+
inputSchema: { feedId: z.number().int().positive() },
|
|
108
|
+
}, async ({ feedId }) => {
|
|
109
|
+
try {
|
|
110
|
+
unsubscribeFromFeed(userId, feedId);
|
|
111
|
+
return textResult({ ok: true });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (scope === "write")
|
|
118
|
+
server.registerTool("save_to_inbox", {
|
|
119
|
+
title: "Save article or note to Inbox",
|
|
120
|
+
description: "Saves a web article URL, raw HTML, text clipping, or markdown note directly into the user's Universal Inbox.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
url: mcpUrlSchema.optional().describe("Web page URL to fetch, extract, and save"),
|
|
123
|
+
title: z.string().max(500).optional().describe("Optional custom title"),
|
|
124
|
+
contentHtml: z.string().optional().describe("Optional pre-rendered HTML content"),
|
|
125
|
+
textContent: z.string().optional().describe("Optional plain text or markdown clipping"),
|
|
126
|
+
note: z.string().max(20000).optional().describe("Optional note or summary attached to the saved item"),
|
|
127
|
+
},
|
|
128
|
+
}, async ({ url, title, contentHtml, textContent, note }) => {
|
|
129
|
+
if (!url && !contentHtml && !textContent) {
|
|
130
|
+
return errorResult("At least one of url, contentHtml, or textContent must be provided");
|
|
131
|
+
}
|
|
132
|
+
if (!hasUserCapability(userId, "inbox")) {
|
|
133
|
+
return { ...textResult(capabilityDenial(userId, "inbox")), isError: true };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const result = await saveToInbox(userId, { url, title, contentHtml, textContent, note });
|
|
137
|
+
return textResult({
|
|
138
|
+
success: true,
|
|
139
|
+
itemId: result.itemId,
|
|
140
|
+
title: result.item.title,
|
|
141
|
+
url: result.item.link,
|
|
142
|
+
hasNote: result.hasNote,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof SsrfBlockedError)
|
|
147
|
+
return errorResult(error.message);
|
|
148
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
server.registerTool("list_folders", {
|
|
152
|
+
title: "List folders / categories",
|
|
153
|
+
description: "Lists all feed categories/folders created by the user, including feed and unread counts.",
|
|
154
|
+
inputSchema: {},
|
|
155
|
+
}, async () => textResult(listFoldersForUser(userId)));
|
|
156
|
+
if (scope === "write")
|
|
157
|
+
server.registerTool("create_folder", {
|
|
158
|
+
title: "Create folder",
|
|
159
|
+
description: "Creates a new category/folder to organize feeds.",
|
|
160
|
+
inputSchema: { name: z.string().trim().min(1).max(100) },
|
|
161
|
+
}, async ({ name }) => textResult(createFolder(userId, name)));
|
|
162
|
+
if (scope === "write")
|
|
163
|
+
server.registerTool("move_feed_to_folder", {
|
|
164
|
+
title: "Move feed to folder",
|
|
165
|
+
description: "Assigns a feed to a folder, or removes it from all folders if folderId is null.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
feedId: z.number().int().positive(),
|
|
168
|
+
folderId: z.number().int().positive().nullable(),
|
|
169
|
+
},
|
|
170
|
+
}, async ({ feedId, folderId }) => {
|
|
171
|
+
if (!isUserSubscribed(userId, feedId))
|
|
172
|
+
return errorResult("not_subscribed");
|
|
173
|
+
if (folderId !== null && !findFolderById(userId, folderId))
|
|
174
|
+
return errorResult("folder_not_found");
|
|
175
|
+
updateSubscriptionFolder(userId, feedId, folderId);
|
|
176
|
+
return textResult({ ok: true });
|
|
177
|
+
});
|
|
178
|
+
if (scope === "write")
|
|
179
|
+
server.registerTool("update_feed", {
|
|
180
|
+
title: "Update feed settings",
|
|
181
|
+
description: "Changes a subscription: its feed URL (e.g. when a site moved its feed and polling keeps failing), display name, poll interval, whether the reader may fetch full articles, and whether new articles trigger push notifications or count in the app icon number. A new URL is fetched and validated before it replaces the old one. If the returned feed ID differs from the one passed in, use the new ID from now on. The poll interval applies to every subscriber of the feed and has a server-wide minimum.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
feedId: z.number().int().positive(),
|
|
184
|
+
url: mcpUrlSchema.optional().describe("New feed URL or website URL (the feed is auto-discovered)"),
|
|
185
|
+
label: z.string().max(200).nullable().optional().describe("Custom display name; null or empty restores the feed title"),
|
|
186
|
+
pollIntervalMinutes: z.number().int().positive().max(10_080).optional(),
|
|
187
|
+
fullText: z.enum(["auto", "never"]).optional().describe("auto: the reader may fetch full articles; never: always show the feed content"),
|
|
188
|
+
notify: z.boolean().optional().describe("Send push notifications to the user's devices when this feed has new articles"),
|
|
189
|
+
badge: z.boolean().optional().describe("Count this feed's unread articles in the number on the app icon"),
|
|
190
|
+
},
|
|
191
|
+
}, async ({ feedId, url, label, pollIntervalMinutes, fullText, notify, badge }) => {
|
|
192
|
+
try {
|
|
193
|
+
let targetFeedId = feedId;
|
|
194
|
+
let previousItemsKept;
|
|
195
|
+
if (url) {
|
|
196
|
+
const changed = await changeFeedUrl(userId, feedId, url);
|
|
197
|
+
targetFeedId = changed.subscription.id;
|
|
198
|
+
previousItemsKept = changed.previousItemsKept;
|
|
199
|
+
}
|
|
200
|
+
const subscription = updateFeedSettings(userId, targetFeedId, { label, pollIntervalMinutes, fullTextMode: fullText, notify, badge });
|
|
201
|
+
return textResult(previousItemsKept === undefined ? subscription : { ...subscription, previousItemsKept });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof MultipleFeedsFoundError) {
|
|
205
|
+
return textResult({
|
|
206
|
+
error: "multiple_feeds_found",
|
|
207
|
+
message: "Multiple feeds found. Call again with one of these feed URLs:",
|
|
208
|
+
feeds: error.feeds,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (error instanceof SsrfBlockedError)
|
|
212
|
+
return errorResult(error.message);
|
|
213
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
if (scope === "write")
|
|
217
|
+
server.registerTool("rename_folder", {
|
|
218
|
+
title: "Rename folder",
|
|
219
|
+
description: "Renames one of the user's folders.",
|
|
220
|
+
inputSchema: { folderId: z.number().int().positive(), name: z.string().trim().min(1).max(100) },
|
|
221
|
+
}, async ({ folderId, name }) => {
|
|
222
|
+
if (!findFolderById(userId, folderId))
|
|
223
|
+
return errorResult("folder_not_found");
|
|
224
|
+
try {
|
|
225
|
+
return textResult(updateFolder(userId, folderId, name));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return errorResult("folder_name_taken");
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
if (scope === "write")
|
|
232
|
+
server.registerTool("delete_folder", {
|
|
233
|
+
title: "Delete folder",
|
|
234
|
+
description: "Deletes a folder. Its feeds stay subscribed and move out of any folder.",
|
|
235
|
+
inputSchema: { folderId: z.number().int().positive() },
|
|
236
|
+
}, async ({ folderId }) => {
|
|
237
|
+
if (!findFolderById(userId, folderId))
|
|
238
|
+
return errorResult("folder_not_found");
|
|
239
|
+
deleteFolder(userId, folderId);
|
|
240
|
+
return textResult({ ok: true });
|
|
241
|
+
});
|
|
242
|
+
server.registerTool("get_new_items", {
|
|
243
|
+
title: "Get new feed items",
|
|
244
|
+
description: "Returns unread items, optionally filtered by feed, folder, search term, or bookmarks.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
feedId: z.number().int().positive().optional(),
|
|
247
|
+
folderId: z.number().int().positive().optional(),
|
|
248
|
+
search: z.string().max(200).optional(),
|
|
249
|
+
bookmarkedOnly: z.boolean().optional(),
|
|
250
|
+
...publishedRange,
|
|
251
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
252
|
+
includeContent: z.boolean().optional().describe("Set to false to leave out the article HTML and save tokens (default: true)"),
|
|
253
|
+
snippetChars: z.number().int().positive().max(2000).optional().describe("Shorten each item's summary to this many characters"),
|
|
254
|
+
},
|
|
255
|
+
}, async ({ feedId, folderId, search, bookmarkedOnly, since, until, limit, includeContent, snippetChars }) => textResult(listItemsForUser(userId, {
|
|
256
|
+
feedId, folderId, search, bookmarkedOnly, limit, unreadOnly: true, publishedSince: since, publishedUntil: until,
|
|
257
|
+
}).map(({ content_html, full_content_html, ...item }) => ({
|
|
258
|
+
...item,
|
|
259
|
+
...(includeContent === false ? {} : { content_html, full_content_html }),
|
|
260
|
+
content_snippet: snippetChars ? truncate(item.content_snippet, snippetChars) : item.content_snippet,
|
|
261
|
+
}))));
|
|
262
|
+
server.registerTool("get_digest", {
|
|
263
|
+
title: "Digest of new articles",
|
|
264
|
+
description: "Returns articles grouped by feed with short snippets, newest first: the cheapest way to see what is new. Defaults to unread articles. Use hours (e.g. 24) or since to limit the period, and maxItemsPerFeed / snippetChars to control size. Follow up with get_item for the full article.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
feedId: z.number().int().positive().optional(),
|
|
267
|
+
folderId: z.number().int().positive().optional(),
|
|
268
|
+
unreadOnly: z.boolean().optional().describe("Include read articles too when false (default: true)"),
|
|
269
|
+
hours: z.number().int().positive().max(720).optional().describe("Only articles published in the last N hours"),
|
|
270
|
+
since: dateInput.optional().describe("Only articles published at or after this ISO date or date-time (overrides hours)"),
|
|
271
|
+
maxItemsPerFeed: z.number().int().positive().max(20).optional().describe("Articles listed per feed (default: 5)"),
|
|
272
|
+
snippetChars: z.number().int().positive().max(1000).optional().describe("Length of each snippet (default: 200)"),
|
|
273
|
+
},
|
|
274
|
+
}, async (args) => textResult(buildDigest(userId, args)));
|
|
275
|
+
server.registerTool("get_overview", {
|
|
276
|
+
title: "Account overview",
|
|
277
|
+
description: "Returns counts for the whole account: subscribed feeds, unread and saved articles, the feeds with the most unread articles, folders with their unread counts, and feeds that are failing.",
|
|
278
|
+
inputSchema: {},
|
|
279
|
+
}, async () => textResult(buildOverview(userId)));
|
|
280
|
+
server.registerTool("list_items", {
|
|
281
|
+
title: "List items with a cursor",
|
|
282
|
+
description: "Lists compact article records by time added. Use nextCursor as before to fetch older pages, or newestCursor as after to fetch newly added items. When paging new items, keep the original after cursor while following nextCursor.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
feedId: z.number().int().positive().optional(),
|
|
285
|
+
folderId: z.number().int().positive().optional(),
|
|
286
|
+
unreadOnly: z.boolean().optional(),
|
|
287
|
+
bookmarkedOnly: z.boolean().optional(),
|
|
288
|
+
before: z.string().max(256).optional(),
|
|
289
|
+
after: z.string().max(256).optional(),
|
|
290
|
+
...publishedRange,
|
|
291
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
292
|
+
},
|
|
293
|
+
}, async (args) => {
|
|
294
|
+
try {
|
|
295
|
+
return textResult(listItemsPage(userId, args));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
server.registerTool("get_item", {
|
|
302
|
+
title: "Get an article",
|
|
303
|
+
description: "Returns one subscribed article, including cached feed or reader content when available. Content is paginated at 40,000 characters; follow nextOffset with offset and the returned content.revision to read every part. Pass maxChars for less.",
|
|
304
|
+
inputSchema: {
|
|
305
|
+
itemId: z.number().int().positive(),
|
|
306
|
+
maxChars: z.number().int().positive().max(40_000).optional().describe("Shorten the content to this many characters"),
|
|
307
|
+
format: z.enum(["html", "text"]).optional().describe("Use text to read plain publisher text without HTML markup"),
|
|
308
|
+
offset: z.number().int().nonnegative().max(10_000_000).optional(),
|
|
309
|
+
revision: z.number().int().positive().optional().describe("Keep pagination on the same content revision"),
|
|
310
|
+
},
|
|
311
|
+
}, async ({ itemId, maxChars, offset, revision, format }) => {
|
|
312
|
+
try {
|
|
313
|
+
const item = getItemForMcp(userId, itemId, maxChars, offset, revision, format);
|
|
314
|
+
return item ? textResult(item) : errorResult("item_not_found");
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return errorResult("content_revision_conflict");
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
if (scope === "write")
|
|
321
|
+
server.registerTool("fetch_full_text", {
|
|
322
|
+
title: "Fetch an article's full text",
|
|
323
|
+
description: "Downloads the full article from its website when the feed only has a teaser, caches it and returns it. Content is capped at 40,000 characters; use get_item with nextOffset and content.revision for remaining text.",
|
|
324
|
+
inputSchema: {
|
|
325
|
+
itemId: z.number().int().positive(),
|
|
326
|
+
force: z.boolean().optional().describe("Fetch again even when a cached full text exists"),
|
|
327
|
+
},
|
|
328
|
+
}, async ({ itemId, force }) => {
|
|
329
|
+
if (!hasUserCapability(userId, "fulltext"))
|
|
330
|
+
return { ...textResult(capabilityDenial(userId, "fulltext")), isError: true };
|
|
331
|
+
const result = await loadFullText(userId, itemId, { force });
|
|
332
|
+
if (!result.ok)
|
|
333
|
+
return errorResult(result.message ? `${result.error}: ${result.message}` : result.error);
|
|
334
|
+
return textResult({
|
|
335
|
+
id: result.id,
|
|
336
|
+
title: result.title ?? undefined,
|
|
337
|
+
byline: result.byline ?? undefined,
|
|
338
|
+
cached: result.cached,
|
|
339
|
+
...getItemForMcp(userId, itemId),
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
server.registerTool("search_items", {
|
|
343
|
+
title: "Search feed items",
|
|
344
|
+
description: "Searches item titles and summaries for a phrase across all items the user can access.",
|
|
345
|
+
inputSchema: {
|
|
346
|
+
query: z.string().min(1).max(200),
|
|
347
|
+
feedId: z.number().int().positive().optional(),
|
|
348
|
+
folderId: z.number().int().positive().optional(),
|
|
349
|
+
bookmarkedOnly: z.boolean().optional(),
|
|
350
|
+
...publishedRange,
|
|
351
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
352
|
+
},
|
|
353
|
+
}, async ({ query, feedId, folderId, bookmarkedOnly, since, until, limit }) => textResult(listItemsForUser(userId, {
|
|
354
|
+
search: query, feedId, folderId, bookmarkedOnly, limit, publishedSince: since, publishedUntil: until,
|
|
355
|
+
})));
|
|
356
|
+
const itemActions = [
|
|
357
|
+
["mark_read", "Mark items as read", "Marks one or more subscribed items as read.", markItemRead],
|
|
358
|
+
["mark_unread", "Mark items as unread", "Marks one or more subscribed items as unread.", markItemUnread],
|
|
359
|
+
["bookmark_item", "Bookmark items", "Saves one or more items for later. Saved items keep their full text and images, survive retention cleanup and unsubscribing.", (userId, itemId) => {
|
|
360
|
+
const changed = bookmarkItem(userId, itemId);
|
|
361
|
+
if (changed)
|
|
362
|
+
scheduleArchive(itemId);
|
|
363
|
+
return changed;
|
|
364
|
+
}],
|
|
365
|
+
["unbookmark_item", "Remove bookmarks", "Removes the bookmark from one or more items.", (userId, itemId) => {
|
|
366
|
+
const changed = unbookmarkItem(userId, itemId);
|
|
367
|
+
if (changed)
|
|
368
|
+
pruneArchive();
|
|
369
|
+
return changed;
|
|
370
|
+
}],
|
|
371
|
+
];
|
|
372
|
+
if (scope === "write")
|
|
373
|
+
for (const [name, title, description, change] of itemActions) {
|
|
374
|
+
server.registerTool(name, { title, description, inputSchema: itemSelection }, async (args) => {
|
|
375
|
+
if (!hasUserCapability(userId, "sync"))
|
|
376
|
+
return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
|
|
377
|
+
const itemIds = selectedItemIds(args);
|
|
378
|
+
if (itemIds.length === 0)
|
|
379
|
+
return errorResult("Provide itemId or itemIds");
|
|
380
|
+
// `updated` counts items whose state actually changed.
|
|
381
|
+
return textResult({ ok: true, updated: applyToItems(itemIds, (itemId) => change(userId, itemId)) });
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (scope === "write")
|
|
385
|
+
server.registerTool("mark_all_read", {
|
|
386
|
+
title: "Mark all items as read",
|
|
387
|
+
description: "Marks all subscribed items as read, optionally limited to one feed or folder.",
|
|
388
|
+
inputSchema: {
|
|
389
|
+
feedId: z.number().int().positive().optional(),
|
|
390
|
+
folderId: z.number().int().positive().optional(),
|
|
391
|
+
},
|
|
392
|
+
}, async ({ feedId, folderId }) => hasUserCapability(userId, "sync")
|
|
393
|
+
? textResult({ marked: markAllRead(userId, { feedId, folderId }) })
|
|
394
|
+
: { ...textResult(capabilityDenial(userId, "sync")), isError: true });
|
|
395
|
+
server.registerTool("list_muted_keywords", {
|
|
396
|
+
title: "List muted keywords",
|
|
397
|
+
description: "Lists all muted keywords configured for the current user.",
|
|
398
|
+
inputSchema: {},
|
|
399
|
+
}, async () => textResult(listMutedKeywords(userId)));
|
|
400
|
+
if (scope === "write")
|
|
401
|
+
server.registerTool("add_muted_keyword", {
|
|
402
|
+
title: "Add muted keyword",
|
|
403
|
+
description: "Mutes a keyword. Articles containing this keyword will be filtered out from your feed.",
|
|
404
|
+
inputSchema: { keyword: z.string().min(1).max(100) },
|
|
405
|
+
}, async ({ keyword }) => textResult(addMutedKeyword(userId, keyword)));
|
|
406
|
+
if (scope === "write")
|
|
407
|
+
server.registerTool("remove_muted_keyword", {
|
|
408
|
+
title: "Remove muted keyword",
|
|
409
|
+
description: "Unmutes a keyword by its ID.",
|
|
410
|
+
inputSchema: { keywordId: z.number().int().positive() },
|
|
411
|
+
}, async ({ keywordId }) => {
|
|
412
|
+
removeMutedKeyword(userId, keywordId);
|
|
413
|
+
return textResult({ ok: true });
|
|
414
|
+
});
|
|
415
|
+
server.registerTool("export_opml", {
|
|
416
|
+
title: "Export feeds as OPML",
|
|
417
|
+
description: "Exports all subscribed feeds of the current user as an OPML 2.0 XML string.",
|
|
418
|
+
inputSchema: {},
|
|
419
|
+
}, async () => ({ content: [{ type: "text", text: generateOpml(userId) }] }));
|
|
420
|
+
if (scope === "write")
|
|
421
|
+
server.registerTool("import_opml", {
|
|
422
|
+
title: "Import feeds from OPML",
|
|
423
|
+
description: "Imports feeds from an OPML 2.0 XML string for the current user.",
|
|
424
|
+
inputSchema: {
|
|
425
|
+
opml: z.string().min(1).describe("The OPML XML content to import"),
|
|
426
|
+
},
|
|
427
|
+
}, async ({ opml }) => {
|
|
428
|
+
try {
|
|
429
|
+
const result = await importOpmlFeeds(userId, opml);
|
|
430
|
+
return textResult(result);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
if (scope === "write")
|
|
437
|
+
server.registerTool("refresh_feed", {
|
|
438
|
+
title: "Refresh a feed",
|
|
439
|
+
description: "Requests an update of a subscribed feed and returns its health. Respects source pauses; deferred=true means work is still pending.",
|
|
440
|
+
inputSchema: {
|
|
441
|
+
feedId: z.number().int().positive().describe("The ID of the feed to refresh"),
|
|
442
|
+
},
|
|
443
|
+
}, async ({ feedId }) => {
|
|
444
|
+
const feed = findFeedById(feedId);
|
|
445
|
+
if (!feed || !isUserSubscribed(userId, feedId)) {
|
|
446
|
+
return errorResult("Feed not found or not subscribed");
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const result = await pollFeed(feed);
|
|
450
|
+
const updated = listSubscriptionsForUser(userId).find((f) => f.id === feedId);
|
|
451
|
+
return textResult({
|
|
452
|
+
success: !result.error,
|
|
453
|
+
newItems: result.newItems,
|
|
454
|
+
error: result.error,
|
|
455
|
+
deferred: result.deferred ?? false,
|
|
456
|
+
retryAt: result.retryAt,
|
|
457
|
+
feed: updated,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
const editionAction = (action) => {
|
|
465
|
+
try {
|
|
466
|
+
return textResult(action());
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (!(error instanceof NativeError))
|
|
470
|
+
throw error;
|
|
471
|
+
return { ...textResult({ error: error.code, ...(error.current !== undefined ? { current: error.current } : {}) }), isError: true };
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
const capabilityAction = (capability, action) => {
|
|
475
|
+
if (!hasUserCapability(userId, capability))
|
|
476
|
+
return { ...textResult(capabilityDenial(userId, capability)), isError: true };
|
|
477
|
+
return editionAction(action);
|
|
478
|
+
};
|
|
479
|
+
server.registerTool("get_native_preferences", {
|
|
480
|
+
title: "Get native reading preferences",
|
|
481
|
+
description: "Returns the user's app-only folder order, newspaper exclusions, preferred sections, time zone and edition size. These settings do not change the web reader.",
|
|
482
|
+
inputSchema: {},
|
|
483
|
+
}, async () => textResult(getNativePreferences(userId)));
|
|
484
|
+
server.registerTool("get_edition", {
|
|
485
|
+
title: "Get current edition",
|
|
486
|
+
description: "Returns the authoritative edition state and revision, including when absent, expired or dismissed. Call before publishing with expectedRevision.",
|
|
487
|
+
inputSchema: {},
|
|
488
|
+
}, async () => textResult(getEditionState(userId)));
|
|
489
|
+
server.registerTool("get_edition_candidates", {
|
|
490
|
+
title: "Get edition candidates",
|
|
491
|
+
description: "Returns a bounded shortlist of unread articles from the last seven days, respecting muted keywords and app-only section visibility. Includes the current revision and user preferences. Includes sourceName and folderName. Use get_item to inspect selected articles, then publish_edition with expectedRevision and a stable requestId.",
|
|
492
|
+
inputSchema: { limit: z.number().int().min(1).max(200).default(100) },
|
|
493
|
+
}, async ({ limit }) => capabilityAction("editions", () => ({ preferences: getNativePreferences(userId), current: getEditionState(userId), candidates: editionCandidates(userId, new Date(), limit) })));
|
|
494
|
+
if (scope === "write")
|
|
495
|
+
server.registerTool("update_native_preferences", {
|
|
496
|
+
title: "Update native reading preferences",
|
|
497
|
+
description: "Patches app-only preferences using expectedRevision and a UUID requestId. Retried requests return their original result; conflicts include the current settings. Folder IDs must belong to the current user. Never changes the web folder list or stream.",
|
|
498
|
+
inputSchema: preferencePatchSchema.shape,
|
|
499
|
+
}, async (input) => capabilityAction("sync", () => patchNativePreferences(userId, input)));
|
|
500
|
+
if (scope === "write")
|
|
501
|
+
server.registerTool("publish_edition", {
|
|
502
|
+
title: "Publish curated edition",
|
|
503
|
+
description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged.",
|
|
504
|
+
inputSchema: publishEditionFields,
|
|
505
|
+
}, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
|
|
506
|
+
if (scope === "write")
|
|
507
|
+
server.registerTool("generate_edition", {
|
|
508
|
+
title: "Generate an automatic edition",
|
|
509
|
+
description: "Ensures an automatic edition exists without disturbing a valid current issue. force=true requests a new automatic selection and requires expectedRevision. A valid curated edition must be explicitly dismissed first. Use a stable UUID requestId for retries.",
|
|
510
|
+
inputSchema: generateEditionSchema.shape,
|
|
511
|
+
}, async (input) => capabilityAction("editions", () => {
|
|
512
|
+
const parsed = generateEditionSchema.safeParse(input);
|
|
513
|
+
if (!parsed.success)
|
|
514
|
+
throw new NativeError("invalid_input");
|
|
515
|
+
return { edition: generateEdition(userId, parsed.data), current: getEditionState(userId) };
|
|
516
|
+
}));
|
|
517
|
+
if (scope === "write")
|
|
518
|
+
server.registerTool("dismiss_edition", {
|
|
519
|
+
title: "Dismiss the current edition",
|
|
520
|
+
description: "Explicitly dismisses the current edition, including curated issues, using a revision and stable UUID requestId. Automatic scheduling waits until the next time slot; generate_edition with force=true can start a new issue immediately.",
|
|
521
|
+
inputSchema: { requestId: z.uuid(), expectedRevision: z.number().int().min(0) },
|
|
522
|
+
}, async (input) => editionAction(() => dismissEdition(userId, input)));
|
|
523
|
+
const currentUser = findUserById(userId);
|
|
524
|
+
if (scope === "write" && currentUser?.role === "admin") {
|
|
525
|
+
server.registerTool("cleanup_database", {
|
|
526
|
+
title: "Clean up database and retention",
|
|
527
|
+
description: "Admin tool: runs item retention cleanup and VACUUM to purge old/read items and reclaim disk space.",
|
|
528
|
+
inputSchema: {
|
|
529
|
+
retentionReadDays: z.number().int().min(0).optional().describe("Override read items retention in days"),
|
|
530
|
+
retentionMaxDays: z.number().int().min(0).optional().describe("Override total item age retention in days"),
|
|
531
|
+
retentionMaxItemsPerFeed: z.number().int().min(0).optional().describe("Override maximum items per feed cap"),
|
|
532
|
+
},
|
|
533
|
+
}, async (args) => {
|
|
534
|
+
try {
|
|
535
|
+
const result = runCleanup(args);
|
|
536
|
+
const stats = getDatabaseStats();
|
|
537
|
+
return textResult({ result, stats });
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
registerPromptsAndResources(server, userId, scope);
|
|
545
|
+
return server;
|
|
546
|
+
}
|
|
547
|
+
//# sourceMappingURL=server.js.map
|