aiquila-mcp 0.3.11 → 0.3.13

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.
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { join, dirname } from 'node:path';
5
5
  import { logger } from '../logger.js';
6
6
  const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
7
7
  const REFRESH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -9,6 +9,39 @@ const DEFAULT_STATE_DIR = '/app/state';
9
9
  function stateDir() {
10
10
  return (process.env.MCP_AUTH_STATE_DIR ?? DEFAULT_STATE_DIR).replace(/\/+$/, '');
11
11
  }
12
+ // True once we've emitted the loud "state dir not writable" warning, so the
13
+ // startup probe and the first failed persist warn at most once between them —
14
+ // subsequent persist failures drop to debug to avoid flooding the logs.
15
+ let warnedUnwritable = false;
16
+ /**
17
+ * Operator-facing remediation for an unwritable state directory. The fix is to
18
+ * recreate the (root-owned) Docker named volume so a fresh one inherits the
19
+ * image's node ownership — `docker compose exec ... chown` cannot be used here
20
+ * because the container would otherwise be in a crash loop. See discussion #342.
21
+ */
22
+ export function stateUnwritableMessage(dir, code) {
23
+ return (`State directory ${dir} is not writable${code ? ` (${code})` : ''} — ` +
24
+ `OAuth tokens will NOT persist; clients must re-authenticate after every restart. ` +
25
+ `To restore persistence, recreate the state volume (this clears existing tokens; ` +
26
+ `clients re-authenticate once):\n` +
27
+ ` docker compose down mcp && docker volume rm <project>_mcp_state && docker compose up -d mcp\n` +
28
+ `See https://github.com/elgorro/aiquila/discussions/342`);
29
+ }
30
+ /**
31
+ * Marks the unwritable-state warning as already emitted (called by the startup
32
+ * probe in the HTTP transport) so the first failed persist does not warn twice.
33
+ */
34
+ export function markStateUnwritableWarned() {
35
+ warnedUnwritable = true;
36
+ }
37
+ function warnPersistFailed(dir, code, err) {
38
+ if (warnedUnwritable) {
39
+ logger.debug({ dir, code, err }, '[state] persist failed — state dir not writable');
40
+ return;
41
+ }
42
+ warnedUnwritable = true;
43
+ logger.warn({ dir, code }, stateUnwritableMessage(dir, code));
44
+ }
12
45
  function ensureDir(dir) {
13
46
  try {
14
47
  mkdirSync(dir, { recursive: true });
@@ -49,7 +82,10 @@ function saveJson(filePath, data) {
49
82
  catch {
50
83
  // ignore cleanup failure
51
84
  }
52
- throw err;
85
+ // Graceful degradation: a persist failure must never crash a request or the
86
+ // process. The server keeps running with in-memory state (tokens just won't
87
+ // survive a restart) and warns the operator how to fix it. See discussion #342.
88
+ warnPersistFailed(dirname(filePath), err.code, err);
53
89
  }
54
90
  }
55
91
  export class StateDirNotWritableError extends Error {
@@ -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,
@@ -7,7 +7,7 @@ import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
7
7
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
8
8
  import { createServer, SERVER_VERSION } from '../server.js';
9
9
  import { NextcloudOAuthProvider } from '../auth/provider.js';
10
- import { probeStateDir, StateDirNotWritableError } from '../auth/store.js';
10
+ import { probeStateDir, StateDirNotWritableError, stateUnwritableMessage, markStateUnwritableWarned, } from '../auth/store.js';
11
11
  import { loginHandler } from '../auth/login.js';
12
12
  import { logger } from '../logger.js';
13
13
  import { fetchStatus } from '../client/ocs.js';
@@ -182,11 +182,16 @@ export async function startHttp() {
182
182
  }
183
183
  catch (err) {
184
184
  if (err instanceof StateDirNotWritableError) {
185
- logger.fatal({ dir: err.dir, code: err.cause.code }, `[startup] State directory is not writable — refresh tokens cannot be persisted. ` +
186
- `Fix volume ownership and restart:\n docker compose exec -u 0 mcp chown -R node:node ${err.dir}\n docker compose restart mcp`);
187
- process.exit(1);
185
+ // Degrade gracefully rather than crash-loop: the server still serves
186
+ // requests, it just can't persist OAuth tokens until the operator fixes
187
+ // the volume. Crashing here was un-fixable under `restart: unless-stopped`
188
+ // because `docker compose exec` needs a running container (discussion #342).
189
+ logger.warn({ dir: err.dir, code: err.cause.code }, stateUnwritableMessage(err.dir, err.cause.code));
190
+ markStateUnwritableWarned();
191
+ }
192
+ else {
193
+ throw err;
188
194
  }
189
- throw err;
190
195
  }
191
196
  const provider = new NextcloudOAuthProvider();
192
197
  // When gated dynamic registration is desired, require a bearer token on POST /register.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "AIquila - MCP server for Nextcloud integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",