aiquila-mcp 0.3.12 → 0.3.15

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.
@@ -175,6 +175,28 @@ function ensureVTimezone(icalData, tzid) {
175
175
  const vtz = buildVTimezone(tzid);
176
176
  return icalData.replace(/BEGIN:VEVENT/, `${vtz}\r\nBEGIN:VEVENT`);
177
177
  }
178
+ /**
179
+ * Build a DISPLAY VALARM block firing `minutes` before the event. Returns an
180
+ * empty string for non-positive values (those are skipped, not emitted).
181
+ */
182
+ function buildVAlarm(minutes) {
183
+ if (minutes <= 0)
184
+ return '';
185
+ const totalSeconds = minutes * 60;
186
+ const hours = Math.floor(totalSeconds / 3600);
187
+ const mins = Math.floor((totalSeconds % 3600) / 60);
188
+ const durationStr = `-PT${hours > 0 ? hours + 'H' : ''}${mins > 0 ? mins + 'M' : ''}`;
189
+ return `BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:${durationStr}\r\nEND:VALARM`;
190
+ }
191
+ /**
192
+ * Merge the single `alarm` and the `alarms` array into one list of reminder
193
+ * minutes. `alarms` entries come first, then `alarm` (if a positive number).
194
+ * The MCP-client-friendly split exists because a union type's anyOf JSON Schema
195
+ * is not reliably honored, so a dedicated array parameter is needed.
196
+ */
197
+ function collectAlarmMinutes(alarm, alarms) {
198
+ return [...(alarms ?? []), ...(alarm !== undefined && alarm !== null ? [alarm] : [])];
199
+ }
178
200
  // ---------------------------------------------------------------------------
179
201
  // Parsing
180
202
  // ---------------------------------------------------------------------------
@@ -879,7 +901,11 @@ export const createEventTool = {
879
901
  alarm: z
880
902
  .number()
881
903
  .optional()
882
- .describe('Reminder in minutes before the event (e.g. 15 for 15 min before)'),
904
+ .describe('Single reminder in minutes before the event (e.g. 15 for 15 min before). For multiple reminders use alarms[].'),
905
+ alarms: z
906
+ .array(z.number())
907
+ .optional()
908
+ .describe('Multiple reminders in minutes before the event (e.g. [1440, 60] for 1 day and 1 hour before). Combined with alarm if both are set.'),
883
909
  tzid: z
884
910
  .string()
885
911
  .optional()
@@ -990,14 +1016,11 @@ export const createEventTool = {
990
1016
  vevent += `\r\n${atLine}`;
991
1017
  }
992
1018
  }
993
- // Add alarm
994
- if (args.alarm !== undefined && args.alarm > 0) {
995
- const sign = '-';
996
- const totalSeconds = args.alarm * 60;
997
- const hours = Math.floor(totalSeconds / 3600);
998
- const minutes = Math.floor((totalSeconds % 3600) / 60);
999
- const durationStr = `${sign}PT${hours > 0 ? hours + 'H' : ''}${minutes > 0 ? minutes + 'M' : ''}`;
1000
- vevent += `\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:${durationStr}\r\nEND:VALARM`;
1019
+ // Add alarms — one VALARM block per reminder (alarm + alarms merged)
1020
+ for (const mins of collectAlarmMinutes(args.alarm, args.alarms)) {
1021
+ const valarm = buildVAlarm(mins);
1022
+ if (valarm)
1023
+ vevent += `\r\n${valarm}`;
1001
1024
  }
1002
1025
  vevent += `\r\nEND:VEVENT\r\nEND:VCALENDAR`;
1003
1026
  const response = await fetchCalDAV(calDavUrl, {
@@ -1075,7 +1098,11 @@ export const updateEventTool = {
1075
1098
  .number()
1076
1099
  .nullable()
1077
1100
  .optional()
1078
- .describe('Reminder in minutes before the event, or null to remove existing alarm'),
1101
+ .describe('Single reminder in minutes before the event, or null to remove all existing alarms. For multiple reminders use alarms[].'),
1102
+ alarms: z
1103
+ .array(z.number())
1104
+ .optional()
1105
+ .describe('Multiple reminders in minutes before the event (e.g. [1440, 60]). Replaces existing alarms; combined with alarm if both are set.'),
1079
1106
  attendees: z
1080
1107
  .array(z.object({
1081
1108
  email: z.string().describe('Attendee email'),
@@ -1138,17 +1165,17 @@ export const updateEventTool = {
1138
1165
  modified = modified.replace(/END:VEVENT/, `CATEGORIES:${args.categories.map(escapeICalValue).join(',')}\r\nEND:VEVENT`);
1139
1166
  }
1140
1167
  }
1141
- // Handle alarm (VALARM)
1142
- if (args.alarm !== undefined) {
1143
- // Remove existing VALARM block
1168
+ // Handle alarms (VALARM). Any alarm/alarms input replaces all existing
1169
+ // VALARM blocks; alarm:null clears them without adding new ones.
1170
+ if (args.alarm !== undefined || args.alarms !== undefined) {
1144
1171
  modified = modified.replace(/BEGIN:VALARM[\s\S]*?END:VALARM\r?\n?/g, '');
1145
- if (args.alarm !== null && args.alarm > 0) {
1146
- const sign = '-';
1147
- const totalSeconds = args.alarm * 60;
1148
- const hours = Math.floor(totalSeconds / 3600);
1149
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1150
- const durationStr = `${sign}PT${hours > 0 ? hours + 'H' : ''}${minutes > 0 ? minutes + 'M' : ''}`;
1151
- modified = modified.replace(/END:VEVENT/, `BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:${durationStr}\r\nEND:VALARM\r\nEND:VEVENT`);
1172
+ if (args.alarm !== null) {
1173
+ for (const mins of collectAlarmMinutes(args.alarm, args.alarms)) {
1174
+ const valarm = buildVAlarm(mins);
1175
+ if (valarm) {
1176
+ modified = modified.replace(/END:VEVENT/, `${valarm}\r\nEND:VEVENT`);
1177
+ }
1178
+ }
1152
1179
  }
1153
1180
  }
1154
1181
  // Handle attendees
@@ -1,4 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ import { spawnSync } from 'node:child_process';
3
+ import { writeFileSync, unlinkSync } from 'node:fs';
2
4
  import { z } from 'zod';
3
5
  import { fetchMailAPI } from '../../client/mail.js';
4
6
  import { fetchOCS } from '../../client/ocs.js';
@@ -232,8 +234,10 @@ const readMessageTool = {
232
234
  if (attachments && attachments.length > 0) {
233
235
  text += `\n${'─'.repeat(60)}\nAttachments:`;
234
236
  for (const att of attachments) {
235
- text += `\n • ${att.fileName} (${att.size} bytes)`;
237
+ const idPart = att.id !== undefined ? `[id: ${att.id}] ` : '';
238
+ text += `\n • ${idPart}${att.fileName} (${att.size} bytes)`;
236
239
  }
240
+ text += `\n(Use mail_get_attachment with the message ID and an attachment id to read one.)`;
237
241
  }
238
242
  text += `\n\nMessage ID: ${args.messageId}`;
239
243
  return { content: [{ type: 'text', text }] };
@@ -251,6 +255,96 @@ const readMessageTool = {
251
255
  }
252
256
  },
253
257
  };
258
+ const getAttachmentTool = {
259
+ name: 'mail_get_attachment',
260
+ description: 'Download an email attachment by message ID and attachment ID. ' +
261
+ 'Returns text for text files and calendar invites (ICS), image data for images, ' +
262
+ 'extracted text for PDFs (requires pdftotext). ' +
263
+ 'Attachment IDs are shown in mail_read_message output.',
264
+ inputSchema: z.object({
265
+ messageId: z.number().describe('Message ID (from mail_read_message)'),
266
+ attachmentId: z.string().describe('Attachment ID shown in mail_read_message (e.g. "2")'),
267
+ }),
268
+ handler: async (args) => {
269
+ try {
270
+ const response = await fetchMailAPI(`/messages/${args.messageId}/attachment/${args.attachmentId}`);
271
+ if (!response.ok) {
272
+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
273
+ }
274
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
275
+ const mimeType = contentType.split(';')[0].trim();
276
+ if (mimeType.startsWith('text/') ||
277
+ mimeType === 'application/json' ||
278
+ mimeType === 'application/ics') {
279
+ const text = await response.text();
280
+ return { content: [{ type: 'text', text }] };
281
+ }
282
+ if (mimeType.startsWith('image/')) {
283
+ const buffer = await response.arrayBuffer();
284
+ const base64 = Buffer.from(buffer).toString('base64');
285
+ return { content: [{ type: 'image', data: base64, mimeType }] };
286
+ }
287
+ if (mimeType === 'application/pdf') {
288
+ const buffer = await response.arrayBuffer();
289
+ const safeId = String(args.messageId).replace(/[^a-zA-Z0-9_-]/g, '') +
290
+ '_' +
291
+ String(args.attachmentId).replace(/[^a-zA-Z0-9_-]/g, '');
292
+ const tmpFile = `/tmp/mcp_att_${safeId}.pdf`;
293
+ try {
294
+ writeFileSync(tmpFile, Buffer.from(buffer));
295
+ const result = spawnSync('pdftotext', [tmpFile, '-'], { encoding: 'utf8' });
296
+ unlinkSync(tmpFile);
297
+ if (result.status === 0 && result.stdout.trim().length > 0) {
298
+ return {
299
+ content: [
300
+ {
301
+ type: 'text',
302
+ text: `[UNTRUSTED EXTERNAL CONTENT - PDF ATTACHMENT]\n${result.stdout}\n[END EXTERNAL CONTENT]`,
303
+ },
304
+ ],
305
+ };
306
+ }
307
+ }
308
+ catch {
309
+ try {
310
+ unlinkSync(tmpFile);
311
+ }
312
+ catch {
313
+ /* ignore */
314
+ }
315
+ }
316
+ return {
317
+ content: [
318
+ {
319
+ type: 'text',
320
+ text: `Attachment: ${mimeType}, ${(buffer.byteLength / 1024).toFixed(1)} KB. Could not extract text (pdftotext unavailable or empty).`,
321
+ },
322
+ ],
323
+ };
324
+ }
325
+ const buffer = await response.arrayBuffer();
326
+ return {
327
+ content: [
328
+ {
329
+ type: 'text',
330
+ text: `Attachment: ${mimeType}, ${(buffer.byteLength / 1024).toFixed(1)} KB. Cannot be read inline.`,
331
+ },
332
+ ],
333
+ };
334
+ }
335
+ catch (error) {
336
+ return {
337
+ content: [
338
+ {
339
+ type: 'text',
340
+ text: `Error downloading attachment: ${error instanceof Error ? error.message : String(error)}`,
341
+ },
342
+ ],
343
+ isError: true,
344
+ };
345
+ }
346
+ },
347
+ };
254
348
  const searchMessagesTool = {
255
349
  name: 'mail_search_messages',
256
350
  description: 'Search email messages across all mailboxes by subject or sender. ' +
@@ -502,6 +596,7 @@ export const mailTools = [
502
596
  listMailboxesTool,
503
597
  listMessagesTool,
504
598
  readMessageTool,
599
+ getAttachmentTool,
505
600
  searchMessagesTool,
506
601
  sendMessageTool,
507
602
  deleteMessageTool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.12",
3
+ "version": "0.3.15",
4
4
  "description": "AIquila - MCP server for Nextcloud integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",