aiquila-mcp 0.3.16 → 0.3.18

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.
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { getNextcloudConfig } from '../tools/types.js';
3
+ import { logger } from '../logger.js';
4
+ import { ApiError } from './aiquila.js';
5
+ /**
6
+ * Make an authenticated request to the Nextcloud News REST API v1-3.
7
+ *
8
+ * Base path: /index.php/apps/news/api/v1-3
9
+ *
10
+ * Returns plain JSON (no OCS envelope), like the Notes API.
11
+ */
12
+ export async function fetchNewsAPI(endpoint, options = {}) {
13
+ const config = getNextcloudConfig();
14
+ const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
15
+ let url = `${config.url}/index.php/apps/news/api/v1-3${endpoint}`;
16
+ if (options.queryParams) {
17
+ const params = new URLSearchParams();
18
+ for (const [key, value] of Object.entries(options.queryParams)) {
19
+ if (value !== undefined)
20
+ params.append(key, String(value));
21
+ }
22
+ const qs = params.toString();
23
+ if (qs)
24
+ url += `?${qs}`;
25
+ }
26
+ const headers = {
27
+ Authorization: `Basic ${auth}`,
28
+ 'OCS-APIRequest': 'true',
29
+ Accept: 'application/json',
30
+ };
31
+ let body;
32
+ if (options.body !== undefined) {
33
+ body = JSON.stringify(options.body);
34
+ headers['Content-Type'] = 'application/json';
35
+ }
36
+ const method = options.method ?? 'GET';
37
+ const t0 = Date.now();
38
+ const response = await fetch(url, { method, headers, body });
39
+ logger.trace({ method, url, status: response.status, ms: Date.now() - t0 }, '[news] HTTP');
40
+ if (!response.ok) {
41
+ const text = await response.text();
42
+ throw new ApiError(response.status, response.statusText, text);
43
+ }
44
+ if (response.status === 200 &&
45
+ response.headers.get('content-type')?.includes('application/json')) {
46
+ return (await response.json());
47
+ }
48
+ return undefined;
49
+ }
@@ -15,6 +15,7 @@ import { calendarTools } from './tools/apps/calendar.js';
15
15
  import { cookbookTools } from './tools/apps/cookbook.js';
16
16
  import { deckTools } from './tools/apps/deck.js';
17
17
  import { notesTools } from './tools/apps/notes.js';
18
+ import { newsTools } from './tools/apps/news.js';
18
19
  import { aiquilaTools } from './tools/apps/aiquila.js';
19
20
  import { usersTools } from './tools/apps/users.js';
20
21
  import { groupsTools } from './tools/apps/groups.js';
@@ -67,6 +68,7 @@ export const TOOL_REGISTRY = [
67
68
  { category: 'tasks', appIds: ['tasks'], tools: tasksTools },
68
69
  { category: 'contacts', appIds: ['contacts'], tools: contactsTools },
69
70
  { category: 'notes', appIds: ['notes'], tools: notesTools },
71
+ { category: 'news', appIds: ['news'], tools: newsTools },
70
72
  { category: 'mail', appIds: ['mail'], tools: mailTools },
71
73
  { category: 'deck', appIds: ['deck'], tools: deckTools },
72
74
  { category: 'cookbook', appIds: ['cookbook'], tools: cookbookTools },
@@ -0,0 +1,411 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchNewsAPI } from '../../client/news.js';
4
+ // ── Helpers ─────────────────────────────────────────────────────────────────
5
+ function err(action, error) {
6
+ return {
7
+ content: [
8
+ {
9
+ type: 'text',
10
+ text: `Error ${action}: ${error instanceof Error ? error.message : String(error)}`,
11
+ },
12
+ ],
13
+ isError: true,
14
+ };
15
+ }
16
+ function text(body) {
17
+ return { content: [{ type: 'text', text: body }] };
18
+ }
19
+ /** Strip HTML tags and collapse whitespace, then truncate. */
20
+ function stripHtml(html, max = 280) {
21
+ const plain = html
22
+ .replace(/<[^>]+>/g, ' ')
23
+ .replace(/&nbsp;/g, ' ')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ return plain.length > max ? `${plain.slice(0, max)}…` : plain;
27
+ }
28
+ function formatFeed(f) {
29
+ const parts = [`- **${f.title || '(untitled)'}** — ${f.url}`];
30
+ parts.push(` ID: ${f.id} | Folder: ${f.folderId} | Unread: ${f.unreadCount}`);
31
+ if (f.lastUpdateError)
32
+ parts.push(` ⚠ Update error: ${f.lastUpdateError}`);
33
+ return parts.join('\n');
34
+ }
35
+ function formatItem(i) {
36
+ const flags = [i.unread ? 'unread' : 'read', i.starred ? 'starred' : null]
37
+ .filter(Boolean)
38
+ .join(', ');
39
+ const lines = [`- **${i.title || '(untitled)'}** (ID: ${i.id}, feed: ${i.feedId}, ${flags})`];
40
+ lines.push(` ${i.url}`);
41
+ if (i.author)
42
+ lines.push(` by ${i.author} — ${new Date(i.pubDate * 1000).toISOString()}`);
43
+ else
44
+ lines.push(` ${new Date(i.pubDate * 1000).toISOString()}`);
45
+ if (i.body)
46
+ lines.push(` ${stripHtml(i.body)}`);
47
+ return lines.join('\n');
48
+ }
49
+ // ── Feed tools ──────────────────────────────────────────────────────────────
50
+ export const listFeedsTool = {
51
+ name: 'list_feeds',
52
+ description: 'List all RSS feeds subscribed in the Nextcloud News app, with unread counts.',
53
+ inputSchema: z.object({}),
54
+ handler: async () => {
55
+ try {
56
+ const result = await fetchNewsAPI('/feeds');
57
+ if (!result.feeds || result.feeds.length === 0) {
58
+ return text('No feeds subscribed.');
59
+ }
60
+ const formatted = result.feeds.map(formatFeed).join('\n');
61
+ return text(`Feeds (${result.feeds.length}):\n\n${formatted}`);
62
+ }
63
+ catch (error) {
64
+ return err('listing feeds', error);
65
+ }
66
+ },
67
+ };
68
+ export const addFeedTool = {
69
+ name: 'add_feed',
70
+ description: 'Subscribe to a new RSS feed by URL, optionally placing it in a folder.',
71
+ inputSchema: z.object({
72
+ url: z.string().describe('The RSS/Atom feed URL to subscribe to'),
73
+ folderId: z.number().optional().describe('Folder ID to place the feed in (omit for root)'),
74
+ }),
75
+ handler: async (args) => {
76
+ try {
77
+ const body = { url: args.url };
78
+ if (args.folderId !== undefined)
79
+ body.folderId = args.folderId;
80
+ const result = await fetchNewsAPI('/feeds', { method: 'POST', body });
81
+ const feed = result.feeds?.[0];
82
+ return text(feed
83
+ ? `Subscribed to feed "${feed.title}" (ID: ${feed.id}).`
84
+ : `Subscribed to feed: ${args.url}`);
85
+ }
86
+ catch (error) {
87
+ return err('adding feed', error);
88
+ }
89
+ },
90
+ };
91
+ export const deleteFeedTool = {
92
+ name: 'delete_feed',
93
+ description: 'Delete (unsubscribe from) a feed and all its items.',
94
+ inputSchema: z.object({
95
+ feedId: z.number().describe('The feed ID to delete'),
96
+ }),
97
+ handler: async (args) => {
98
+ try {
99
+ await fetchNewsAPI(`/feeds/${args.feedId}`, { method: 'DELETE' });
100
+ return text(`Deleted feed ${args.feedId}.`);
101
+ }
102
+ catch (error) {
103
+ return err('deleting feed', error);
104
+ }
105
+ },
106
+ };
107
+ export const moveFeedTool = {
108
+ name: 'move_feed',
109
+ description: 'Move a feed into a different folder.',
110
+ inputSchema: z.object({
111
+ feedId: z.number().describe('The feed ID to move'),
112
+ folderId: z.number().describe('Destination folder ID (0 for root)'),
113
+ }),
114
+ handler: async (args) => {
115
+ try {
116
+ await fetchNewsAPI(`/feeds/${args.feedId}/move`, {
117
+ method: 'POST',
118
+ body: { folderId: args.folderId },
119
+ });
120
+ return text(`Moved feed ${args.feedId} to folder ${args.folderId}.`);
121
+ }
122
+ catch (error) {
123
+ return err('moving feed', error);
124
+ }
125
+ },
126
+ };
127
+ export const renameFeedTool = {
128
+ name: 'rename_feed',
129
+ description: 'Rename a feed.',
130
+ inputSchema: z.object({
131
+ feedId: z.number().describe('The feed ID to rename'),
132
+ feedTitle: z.string().describe('The new feed title'),
133
+ }),
134
+ handler: async (args) => {
135
+ try {
136
+ await fetchNewsAPI(`/feeds/${args.feedId}/rename`, {
137
+ method: 'POST',
138
+ body: { feedTitle: args.feedTitle },
139
+ });
140
+ return text(`Renamed feed ${args.feedId} to "${args.feedTitle}".`);
141
+ }
142
+ catch (error) {
143
+ return err('renaming feed', error);
144
+ }
145
+ },
146
+ };
147
+ export const markFeedReadTool = {
148
+ name: 'mark_feed_read',
149
+ description: 'Mark all items in a feed as read up to (and including) the given newest item ID.',
150
+ inputSchema: z.object({
151
+ feedId: z.number().describe('The feed ID'),
152
+ newestItemId: z.number().describe('Mark all items with ID <= this value as read'),
153
+ }),
154
+ handler: async (args) => {
155
+ try {
156
+ await fetchNewsAPI(`/feeds/${args.feedId}/read`, {
157
+ method: 'POST',
158
+ body: { newestItemId: args.newestItemId },
159
+ });
160
+ return text(`Marked feed ${args.feedId} read up to item ${args.newestItemId}.`);
161
+ }
162
+ catch (error) {
163
+ return err('marking feed read', error);
164
+ }
165
+ },
166
+ };
167
+ // ── Folder tools ────────────────────────────────────────────────────────────
168
+ export const listNewsFoldersTool = {
169
+ name: 'list_news_folders',
170
+ description: 'List all folders used to organize feeds in the Nextcloud News app.',
171
+ inputSchema: z.object({}),
172
+ handler: async () => {
173
+ try {
174
+ const result = await fetchNewsAPI('/folders');
175
+ if (!result.folders || result.folders.length === 0) {
176
+ return text('No folders found.');
177
+ }
178
+ const formatted = result.folders.map((f) => `- **${f.name}** (ID: ${f.id})`).join('\n');
179
+ return text(`News folders (${result.folders.length}):\n\n${formatted}`);
180
+ }
181
+ catch (error) {
182
+ return err('listing folders', error);
183
+ }
184
+ },
185
+ };
186
+ export const createNewsFolderTool = {
187
+ name: 'create_news_folder',
188
+ description: 'Create a new folder for organizing feeds in the News app.',
189
+ inputSchema: z.object({
190
+ name: z.string().describe('The folder name'),
191
+ }),
192
+ handler: async (args) => {
193
+ try {
194
+ const result = await fetchNewsAPI('/folders', {
195
+ method: 'POST',
196
+ body: { name: args.name },
197
+ });
198
+ const folder = result.folders?.[0];
199
+ return text(folder
200
+ ? `Created folder "${folder.name}" (ID: ${folder.id}).`
201
+ : `Created folder "${args.name}".`);
202
+ }
203
+ catch (error) {
204
+ return err('creating folder', error);
205
+ }
206
+ },
207
+ };
208
+ export const renameNewsFolderTool = {
209
+ name: 'rename_news_folder',
210
+ description: 'Rename a News folder.',
211
+ inputSchema: z.object({
212
+ folderId: z.number().describe('The folder ID to rename'),
213
+ name: z.string().describe('The new folder name'),
214
+ }),
215
+ handler: async (args) => {
216
+ try {
217
+ await fetchNewsAPI(`/folders/${args.folderId}`, {
218
+ method: 'PUT',
219
+ body: { name: args.name },
220
+ });
221
+ return text(`Renamed folder ${args.folderId} to "${args.name}".`);
222
+ }
223
+ catch (error) {
224
+ return err('renaming folder', error);
225
+ }
226
+ },
227
+ };
228
+ export const deleteNewsFolderTool = {
229
+ name: 'delete_news_folder',
230
+ description: 'Delete a News folder and all feeds it contains.',
231
+ inputSchema: z.object({
232
+ folderId: z.number().describe('The folder ID to delete'),
233
+ }),
234
+ handler: async (args) => {
235
+ try {
236
+ await fetchNewsAPI(`/folders/${args.folderId}`, { method: 'DELETE' });
237
+ return text(`Deleted folder ${args.folderId}.`);
238
+ }
239
+ catch (error) {
240
+ return err('deleting folder', error);
241
+ }
242
+ },
243
+ };
244
+ export const markNewsFolderReadTool = {
245
+ name: 'mark_news_folder_read',
246
+ description: 'Mark all items in a folder as read up to (and including) the given newest item ID.',
247
+ inputSchema: z.object({
248
+ folderId: z.number().describe('The folder ID'),
249
+ newestItemId: z.number().describe('Mark all items with ID <= this value as read'),
250
+ }),
251
+ handler: async (args) => {
252
+ try {
253
+ await fetchNewsAPI(`/folders/${args.folderId}/read`, {
254
+ method: 'POST',
255
+ body: { newestItemId: args.newestItemId },
256
+ });
257
+ return text(`Marked folder ${args.folderId} read up to item ${args.newestItemId}.`);
258
+ }
259
+ catch (error) {
260
+ return err('marking folder read', error);
261
+ }
262
+ },
263
+ };
264
+ // ── Item / article tools ────────────────────────────────────────────────────
265
+ export const listNewsItemsTool = {
266
+ name: 'list_news_items',
267
+ description: 'List news articles (items). Filter by feed, folder, starred, or all; supports pagination and read/unread filtering.',
268
+ inputSchema: z.object({
269
+ type: z
270
+ .enum(['feed', 'folder', 'starred', 'all'])
271
+ .optional()
272
+ .describe('Scope of items to return (default "all")'),
273
+ id: z
274
+ .number()
275
+ .optional()
276
+ .describe('Feed or folder ID when type is "feed" or "folder" (default 0)'),
277
+ batchSize: z.number().optional().describe('Max items to return (default 20, -1 for all)'),
278
+ offset: z.number().optional().describe('Return items older than this item ID (pagination)'),
279
+ getRead: z.boolean().optional().describe('Include already-read items (default false)'),
280
+ oldestFirst: z
281
+ .boolean()
282
+ .optional()
283
+ .describe('Return oldest items first (default newest first)'),
284
+ }),
285
+ handler: async (args) => {
286
+ try {
287
+ const typeMap = { feed: 0, folder: 1, starred: 2, all: 3 };
288
+ const queryParams = {
289
+ type: typeMap[args.type ?? 'all'],
290
+ id: args.id ?? 0,
291
+ batchSize: args.batchSize ?? 20,
292
+ getRead: args.getRead ?? false,
293
+ oldestFirst: args.oldestFirst ?? false,
294
+ };
295
+ if (args.offset !== undefined)
296
+ queryParams.offset = args.offset;
297
+ const result = await fetchNewsAPI('/items', { queryParams });
298
+ if (!result.items || result.items.length === 0) {
299
+ return text('No items found.');
300
+ }
301
+ const formatted = result.items.map(formatItem).join('\n\n');
302
+ return text(`Items (${result.items.length}):\n\n${formatted}`);
303
+ }
304
+ catch (error) {
305
+ return err('listing items', error);
306
+ }
307
+ },
308
+ };
309
+ export const markItemReadTool = {
310
+ name: 'mark_item_read',
311
+ description: 'Mark a single news item as read.',
312
+ inputSchema: z.object({
313
+ itemId: z.number().describe('The item ID'),
314
+ }),
315
+ handler: async (args) => {
316
+ try {
317
+ await fetchNewsAPI(`/items/${args.itemId}/read`, { method: 'POST' });
318
+ return text(`Marked item ${args.itemId} read.`);
319
+ }
320
+ catch (error) {
321
+ return err('marking item read', error);
322
+ }
323
+ },
324
+ };
325
+ export const markItemUnreadTool = {
326
+ name: 'mark_item_unread',
327
+ description: 'Mark a single news item as unread.',
328
+ inputSchema: z.object({
329
+ itemId: z.number().describe('The item ID'),
330
+ }),
331
+ handler: async (args) => {
332
+ try {
333
+ await fetchNewsAPI(`/items/${args.itemId}/unread`, { method: 'POST' });
334
+ return text(`Marked item ${args.itemId} unread.`);
335
+ }
336
+ catch (error) {
337
+ return err('marking item unread', error);
338
+ }
339
+ },
340
+ };
341
+ export const starItemTool = {
342
+ name: 'star_item',
343
+ description: 'Star (favorite) a single news item.',
344
+ inputSchema: z.object({
345
+ itemId: z.number().describe('The item ID'),
346
+ }),
347
+ handler: async (args) => {
348
+ try {
349
+ await fetchNewsAPI(`/items/${args.itemId}/star`, { method: 'POST' });
350
+ return text(`Starred item ${args.itemId}.`);
351
+ }
352
+ catch (error) {
353
+ return err('starring item', error);
354
+ }
355
+ },
356
+ };
357
+ export const unstarItemTool = {
358
+ name: 'unstar_item',
359
+ description: 'Remove the star from a single news item.',
360
+ inputSchema: z.object({
361
+ itemId: z.number().describe('The item ID'),
362
+ }),
363
+ handler: async (args) => {
364
+ try {
365
+ await fetchNewsAPI(`/items/${args.itemId}/unstar`, { method: 'POST' });
366
+ return text(`Unstarred item ${args.itemId}.`);
367
+ }
368
+ catch (error) {
369
+ return err('unstarring item', error);
370
+ }
371
+ },
372
+ };
373
+ export const markItemsReadTool = {
374
+ name: 'mark_items_read',
375
+ description: 'Mark multiple news items as read in one call.',
376
+ inputSchema: z.object({
377
+ itemIds: z.array(z.number()).describe('The item IDs to mark as read'),
378
+ }),
379
+ handler: async (args) => {
380
+ try {
381
+ await fetchNewsAPI('/items/read/multiple', {
382
+ method: 'POST',
383
+ body: { itemIds: args.itemIds },
384
+ });
385
+ return text(`Marked ${args.itemIds.length} item(s) read.`);
386
+ }
387
+ catch (error) {
388
+ return err('marking items read', error);
389
+ }
390
+ },
391
+ };
392
+ // ── Export ──────────────────────────────────────────────────────────────────
393
+ export const newsTools = [
394
+ listFeedsTool,
395
+ addFeedTool,
396
+ deleteFeedTool,
397
+ moveFeedTool,
398
+ renameFeedTool,
399
+ markFeedReadTool,
400
+ listNewsFoldersTool,
401
+ createNewsFolderTool,
402
+ renameNewsFolderTool,
403
+ deleteNewsFolderTool,
404
+ markNewsFolderReadTool,
405
+ listNewsItemsTool,
406
+ markItemReadTool,
407
+ markItemUnreadTool,
408
+ starItemTool,
409
+ unstarItemTool,
410
+ markItemsReadTool,
411
+ ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.16",
4
- "description": "AIquila - MCP server for Nextcloud integration",
3
+ "version": "0.3.18",
4
+ "description": "Nextcloud MCP server — files, calendar, contacts, mail, maps, notes, tasks & 120+ more tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -21,10 +21,15 @@
21
21
  },
22
22
  "keywords": [
23
23
  "mcp",
24
+ "model-context-protocol",
24
25
  "nextcloud",
25
26
  "claude",
26
27
  "ai",
27
- "aiquila"
28
+ "aiquila",
29
+ "webdav",
30
+ "caldav",
31
+ "carddav",
32
+ "self-hosted"
28
33
  ],
29
34
  "author": "sirgorro",
30
35
  "license": "MIT",