aiquila-mcp 0.3.9 → 0.3.11

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.
@@ -14,7 +14,9 @@ function ensureDir(dir) {
14
14
  mkdirSync(dir, { recursive: true });
15
15
  }
16
16
  catch (err) {
17
- logger.warn({ dir, err }, '[state] Failed to create state directory');
17
+ // Best-effort: probeStateDir() (called from startup) is the authoritative
18
+ // check. Logging here would spam non-HTTP transports that never persist.
19
+ logger.debug({ dir, err }, '[state] mkdir failed (probe will report fatal if writes break)');
18
20
  }
19
21
  }
20
22
  function loadJson(filePath) {
@@ -41,13 +43,50 @@ function saveJson(filePath, data) {
41
43
  renameSync(tmp, filePath);
42
44
  }
43
45
  catch (err) {
44
- logger.warn({ file: filePath, err }, '[state] Failed to save state file');
45
46
  try {
46
47
  unlinkSync(tmp);
47
48
  }
48
49
  catch {
49
50
  // ignore cleanup failure
50
51
  }
52
+ throw err;
53
+ }
54
+ }
55
+ export class StateDirNotWritableError extends Error {
56
+ dir;
57
+ cause;
58
+ constructor(dir, cause) {
59
+ super(`State directory ${dir} is not writable (${cause.code}). ` +
60
+ `Fix volume ownership, then restart. For Docker named volumes:\n` +
61
+ ` docker compose exec -u 0 mcp chown -R node:node ${dir}\n` +
62
+ ` docker compose restart mcp`);
63
+ this.dir = dir;
64
+ this.cause = cause;
65
+ this.name = 'StateDirNotWritableError';
66
+ }
67
+ }
68
+ /**
69
+ * Verifies the state directory is usable before the server starts accepting
70
+ * requests. Writes and removes a sentinel file. Throws StateDirNotWritableError
71
+ * on any filesystem permission / read-only / out-of-space failure so the
72
+ * caller can log a fatal-level remediation message and exit.
73
+ */
74
+ export function probeStateDir(dir = stateDir()) {
75
+ try {
76
+ mkdirSync(dir, { recursive: true });
77
+ const probe = join(dir, '.write-probe');
78
+ writeFileSync(probe, String(process.pid), 'utf8');
79
+ unlinkSync(probe);
80
+ }
81
+ catch (err) {
82
+ const errno = err;
83
+ if (errno.code === 'EACCES' ||
84
+ errno.code === 'EPERM' ||
85
+ errno.code === 'EROFS' ||
86
+ errno.code === 'ENOSPC') {
87
+ throw new StateDirNotWritableError(dir, errno);
88
+ }
89
+ throw err;
51
90
  }
52
91
  }
53
92
  export class ClientsStore {
@@ -464,6 +464,45 @@ function formatCalendar(cal) {
464
464
  // ---------------------------------------------------------------------------
465
465
  // CalDAV helpers
466
466
  // ---------------------------------------------------------------------------
467
+ /**
468
+ * Resolve a calendar identifier (display name or URL slug) to its URL slug.
469
+ * Falls back to the input unchanged when no match is found or PROPFIND fails,
470
+ * so existing slug-based callers keep working.
471
+ */
472
+ async function resolveCalendarSlug(nameOrSlug) {
473
+ const config = getNextcloudConfig();
474
+ const calDavUrl = `${config.url}/remote.php/dav/calendars/${config.user}/`;
475
+ const propfindBody = `<?xml version="1.0" encoding="UTF-8"?>
476
+ <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
477
+ <d:prop><d:resourcetype /><d:displayname /></d:prop>
478
+ </d:propfind>`;
479
+ try {
480
+ const response = await fetchCalDAV(calDavUrl, {
481
+ method: 'PROPFIND',
482
+ body: propfindBody,
483
+ headers: { Depth: '1' },
484
+ });
485
+ if (!response.ok)
486
+ return nameOrSlug;
487
+ const text = await response.text();
488
+ const calendars = parseCalendars(text);
489
+ for (const cal of calendars) {
490
+ const slug = cal.url.split('/').filter(Boolean).pop() ?? '';
491
+ if (slug === nameOrSlug)
492
+ return nameOrSlug;
493
+ }
494
+ const lower = nameOrSlug.toLowerCase();
495
+ for (const cal of calendars) {
496
+ if (cal.displayName.toLowerCase() === lower) {
497
+ return cal.url.split('/').filter(Boolean).pop() ?? nameOrSlug;
498
+ }
499
+ }
500
+ }
501
+ catch {
502
+ /* fall through — keep original input */
503
+ }
504
+ return nameOrSlug;
505
+ }
467
506
  /**
468
507
  * Assert that a calendar supports VEVENT. Throws a descriptive error if not,
469
508
  * so create_event fails early with a useful message instead of a 403 from the server.
@@ -481,7 +520,9 @@ async function assertCalendarSupportsEvents(calendarUrl, calendarName) {
481
520
  headers: { Depth: '0' },
482
521
  });
483
522
  if (response.status === 404) {
484
- throw new Error(`Calendar "${calendarName}" not found. Use list_calendars to find available calendar names.`);
523
+ const err = new Error(`Calendar "${calendarName}" not found. Use list_calendars to find available calendar names.`);
524
+ err.code = 'CALENDAR_NOT_FOUND';
525
+ throw err;
485
526
  }
486
527
  if (!response.ok)
487
528
  return; // Can't check — let the server reject if needed
@@ -504,7 +545,8 @@ async function assertCalendarSupportsEvents(calendarUrl, calendarName) {
504
545
  */
505
546
  async function resolveEventByUid(calendarName, uid) {
506
547
  const config = getNextcloudConfig();
507
- const calDavUrl = `${config.url}/remote.php/dav/calendars/${config.user}/${calendarName}/`;
548
+ let slug = calendarName;
549
+ const buildUrl = () => `${config.url}/remote.php/dav/calendars/${config.user}/${slug}/`;
508
550
  const reportBody = `<?xml version="1.0" encoding="UTF-8"?>
509
551
  <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
510
552
  <d:prop>
@@ -521,11 +563,22 @@ async function resolveEventByUid(calendarName, uid) {
521
563
  </c:comp-filter>
522
564
  </c:filter>
523
565
  </c:calendar-query>`;
524
- const response = await fetchCalDAV(calDavUrl, {
566
+ let response = await fetchCalDAV(buildUrl(), {
525
567
  method: 'REPORT',
526
568
  body: reportBody,
527
569
  headers: { Depth: '1' },
528
570
  });
571
+ if (response.status === 404) {
572
+ const resolved = await resolveCalendarSlug(calendarName);
573
+ if (resolved !== calendarName) {
574
+ slug = resolved;
575
+ response = await fetchCalDAV(buildUrl(), {
576
+ method: 'REPORT',
577
+ body: reportBody,
578
+ headers: { Depth: '1' },
579
+ });
580
+ }
581
+ }
529
582
  if (!response.ok) {
530
583
  const errorText = await response.text();
531
584
  throw new Error(`CalDAV REPORT failed for calendar "${calendarName}": ${response.status} - ${errorText}`);
@@ -639,7 +692,8 @@ export const listEventsTool = {
639
692
  handler: async (args) => {
640
693
  try {
641
694
  const config = getNextcloudConfig();
642
- const calDavUrl = `${config.url}/remote.php/dav/calendars/${config.user}/${args.calendarName}/`;
695
+ let slug = args.calendarName;
696
+ const buildUrl = () => `${config.url}/remote.php/dav/calendars/${config.user}/${slug}/`;
643
697
  const limit = args.limit || 50;
644
698
  // Default time range: today to 30 days from now
645
699
  const now = new Date();
@@ -663,11 +717,22 @@ export const listEventsTool = {
663
717
  </c:comp-filter>
664
718
  </c:filter>
665
719
  </c:calendar-query>`;
666
- const response = await fetchCalDAV(calDavUrl, {
720
+ let response = await fetchCalDAV(buildUrl(), {
667
721
  method: 'REPORT',
668
722
  body: reportBody,
669
723
  headers: { Depth: '1' },
670
724
  });
725
+ if (response.status === 404) {
726
+ const resolved = await resolveCalendarSlug(args.calendarName);
727
+ if (resolved !== args.calendarName) {
728
+ slug = resolved;
729
+ response = await fetchCalDAV(buildUrl(), {
730
+ method: 'REPORT',
731
+ body: reportBody,
732
+ headers: { Depth: '1' },
733
+ });
734
+ }
735
+ }
671
736
  if (!response.ok) {
672
737
  const errorText = await response.text();
673
738
  throw new Error(`CalDAV REPORT failed for calendar "${args.calendarName}": ${response.status} - ${errorText}`);
@@ -824,10 +889,30 @@ export const createEventTool = {
824
889
  try {
825
890
  const config = getNextcloudConfig();
826
891
  const eventUid = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
827
- const calendarBaseUrl = `${config.url}/remote.php/dav/calendars/${config.user}/${args.calendarName}/`;
892
+ let slug = args.calendarName;
893
+ const baseUrl = () => `${config.url}/remote.php/dav/calendars/${config.user}/${slug}/`;
894
+ // Validate the calendar supports VEVENT before building and sending the iCal payload.
895
+ // If the original name is a display name (not a slug), retry once after resolving.
896
+ try {
897
+ await assertCalendarSupportsEvents(baseUrl(), args.calendarName);
898
+ }
899
+ catch (err) {
900
+ if (err.code === 'CALENDAR_NOT_FOUND') {
901
+ const resolved = await resolveCalendarSlug(args.calendarName);
902
+ if (resolved !== args.calendarName) {
903
+ slug = resolved;
904
+ await assertCalendarSupportsEvents(baseUrl(), args.calendarName);
905
+ }
906
+ else {
907
+ throw err;
908
+ }
909
+ }
910
+ else {
911
+ throw err;
912
+ }
913
+ }
914
+ const calendarBaseUrl = baseUrl();
828
915
  const calDavUrl = `${calendarBaseUrl}${eventUid}.ics`;
829
- // Validate the calendar supports VEVENT before building and sending the iCal payload
830
- await assertCalendarSupportsEvents(calendarBaseUrl, args.calendarName);
831
916
  const now = icalNow();
832
917
  const isAllDay = args.dtstart.length === 8;
833
918
  const tzid = !isAllDay ? args.tzid : undefined;
@@ -930,7 +1015,7 @@ export const createEventTool = {
930
1015
  }
931
1016
  // Verify the event was actually persisted
932
1017
  try {
933
- await resolveEventByUid(args.calendarName, eventUid);
1018
+ await resolveEventByUid(slug, eventUid);
934
1019
  }
935
1020
  catch {
936
1021
  throw new Error(`Event creation appeared to succeed (HTTP ${response.status}) but the event ` +
@@ -2,13 +2,24 @@
2
2
  import { z } from 'zod';
3
3
  import { fetchMailAPI } from '../../client/mail.js';
4
4
  import { fetchOCS } from '../../client/ocs.js';
5
+ // Invisible padding characters used in newsletter preheaders
6
+ // (U+034F, U+00AD, U+200B–U+200D, U+FEFF)
7
+ const INVISIBLE_CHARS_RE = /[\u034F\u00AD\u200B-\u200D\uFEFF]/g;
5
8
  // ── Helpers ──────────────────────────────────────────────────────────
6
9
  function stripHtml(html) {
7
10
  let text = html;
8
11
  // Remove style and script blocks
9
12
  text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
10
13
  text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
11
- // Convert line-break tags to newlines
14
+ // Image-based newsletters carry content in alt attributes
15
+ text = text.replace(/<img[^>]+alt="([^"]{2,})"[^>]*\/?>/gi, (_, alt) => `\n${alt.trim()}\n`);
16
+ text = text.replace(/<img[^>]+alt='([^']{2,})'[^>]*\/?>/gi, (_, alt) => `\n${alt.trim()}\n`);
17
+ text = text.replace(/<img[^>]*\/?>/gi, '');
18
+ // Block-level structure
19
+ text = text.replace(/<\/h[1-6]>/gi, '\n\n');
20
+ text = text.replace(/<\/tr>/gi, '\n');
21
+ text = text.replace(/<td[^>]*>/gi, ' ');
22
+ text = text.replace(/<th[^>]*>/gi, ' ');
12
23
  text = text.replace(/<br\s*\/?>/gi, '\n');
13
24
  text = text.replace(/<\/p>/gi, '\n\n');
14
25
  text = text.replace(/<\/div>/gi, '\n');
@@ -23,7 +34,15 @@ function stripHtml(html) {
23
34
  text = text.replace(/&quot;/g, '"');
24
35
  text = text.replace(/&#39;/g, "'");
25
36
  text = text.replace(/&nbsp;/g, ' ');
26
- // Collapse multiple blank lines
37
+ text = text.replace(/&#\d+;/g, '');
38
+ // Strip invisible preheader padding
39
+ text = text.replace(INVISIBLE_CHARS_RE, '');
40
+ // Trim per-line and drop blanks before collapsing
41
+ text = text
42
+ .split('\n')
43
+ .map((line) => line.trim())
44
+ .filter((line) => line.length > 0)
45
+ .join('\n');
27
46
  text = text.replace(/\n{3,}/g, '\n\n');
28
47
  return text.trim();
29
48
  }
@@ -80,17 +99,20 @@ const listMailboxesTool = {
80
99
  }),
81
100
  handler: async (args) => {
82
101
  try {
83
- const response = await fetchMailAPI(`/accounts/${args.accountId}/mailboxes`);
102
+ const response = await fetchMailAPI(`/mailboxes?accountId=${args.accountId}`);
84
103
  if (!response.ok) {
85
104
  throw new Error(`HTTP ${response.status}: ${await response.text()}`);
86
105
  }
87
- const mailboxes = (await response.json());
106
+ const data = (await response.json());
107
+ const mailboxes = Array.isArray(data) ? data : (data.mailboxes ?? []);
88
108
  if (mailboxes.length === 0) {
89
109
  return { content: [{ type: 'text', text: 'No mailboxes found.' }] };
90
110
  }
91
111
  const lines = mailboxes.map((m) => {
112
+ const id = m.databaseId ?? m.id;
113
+ const name = m.displayName ?? m.name;
92
114
  const unread = m.unread > 0 ? ` (${m.unread} unread)` : '';
93
- return `• ${m.name}${unread} — ID: ${m.id}`;
115
+ return `• ${name}${unread} — ID: ${id}`;
94
116
  });
95
117
  return {
96
118
  content: [{ type: 'text', text: `Mailboxes:\n${lines.join('\n')}` }],
@@ -128,19 +150,24 @@ const listMessagesTool = {
128
150
  }),
129
151
  handler: async (args) => {
130
152
  try {
131
- const queryParams = {};
153
+ const queryParts = [`mailboxId=${args.mailboxId}`];
132
154
  if (args.limit)
133
- queryParams.limit = String(args.limit);
155
+ queryParts.push(`limit=${args.limit}`);
134
156
  if (args.cursor)
135
- queryParams.cursor = String(args.cursor);
157
+ queryParts.push(`cursor=${args.cursor}`);
136
158
  if (args.filter && args.filter !== 'all')
137
- queryParams.filter = args.filter;
138
- const response = await fetchOCS(`/ocs/v2.php/apps/mail/api/v1/mailboxes/${args.mailboxId}/messages`, { queryParams });
139
- const messages = response.ocs.data;
159
+ queryParts.push(`filter=${args.filter}`);
160
+ const response = await fetchMailAPI(`/messages?${queryParts.join('&')}`);
161
+ if (!response.ok) {
162
+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
163
+ }
164
+ const data = (await response.json());
165
+ const messages = Array.isArray(data) ? data : (data.messages ?? data.data ?? []);
140
166
  if (!messages || messages.length === 0) {
141
167
  return { content: [{ type: 'text', text: 'No messages found.' }] };
142
168
  }
143
169
  const lines = messages.map((msg) => {
170
+ const id = msg.databaseId ?? msg.id;
144
171
  const flags = [];
145
172
  if (!msg.flags.seen)
146
173
  flags.push('UNREAD');
@@ -148,15 +175,16 @@ const listMessagesTool = {
148
175
  flags.push('starred');
149
176
  if (msg.flags.answered)
150
177
  flags.push('replied');
151
- if (msg.hasAttachments)
178
+ if (msg.flags?.hasAttachments ?? msg.hasAttachments)
152
179
  flags.push('attachment');
153
180
  const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
154
181
  const from = formatRecipients(msg.from);
155
- return `• ${msg.subject}${flagStr}\n From: ${from} | ${formatDate(msg.dateInt)} | ID: ${msg.id}`;
182
+ return `• ${msg.subject}${flagStr}\n From: ${from} | ${formatDate(msg.dateInt)} | ID: ${id}`;
156
183
  });
157
184
  let text = `Messages (${messages.length}):\n${lines.join('\n')}`;
158
185
  if (messages.length >= (args.limit || 20)) {
159
- const lastId = messages[messages.length - 1].id;
186
+ const last = messages[messages.length - 1];
187
+ const lastId = last.databaseId ?? last.id;
160
188
  text += `\n\nMore messages available. Use cursor: ${lastId} to load next page.`;
161
189
  }
162
190
  return { content: [{ type: 'text', text }] };
@@ -182,29 +210,25 @@ const readMessageTool = {
182
210
  }),
183
211
  handler: async (args) => {
184
212
  try {
185
- // Fetch metadata via OCS
186
- const metaResponse = await fetchOCS(`/ocs/v2.php/apps/mail/api/v1/message/${args.messageId}`);
187
- const meta = metaResponse.ocs.data;
188
- // Fetch body via Mail REST API
213
+ // Mail 5.x: /messages/{id}/body returns both metadata and body in one call.
189
214
  const bodyResponse = await fetchMailAPI(`/messages/${args.messageId}/body`);
190
- let bodyText = '';
191
- if (bodyResponse.ok) {
192
- const bodyData = (await bodyResponse.json());
193
- const rawBody = bodyData.body || bodyData.data || '';
194
- bodyText = stripHtml(rawBody);
215
+ if (!bodyResponse.ok) {
216
+ throw new Error(`HTTP ${bodyResponse.status}: ${await bodyResponse.text()}`);
195
217
  }
196
- // Format output
197
- const from = formatRecipients(meta.from);
198
- const to = formatRecipients(meta.to);
199
- const cc = formatRecipients(meta.cc || []);
200
- const date = meta.dateInt ? formatDate(meta.dateInt) : 'Unknown';
201
- const subject = meta.subject || '(No subject)';
218
+ const data = (await bodyResponse.json());
219
+ const rawBody = data.body || '';
220
+ const bodyText = rawBody ? stripHtml(rawBody) : '(no body)';
221
+ const from = formatRecipients(data.from);
222
+ const to = formatRecipients(data.to);
223
+ const cc = formatRecipients(data.cc || []);
224
+ const date = data.dateInt ? formatDate(data.dateInt) : 'Unknown';
225
+ const subject = data.subject || '(No subject)';
202
226
  let text = `Subject: ${subject}\nFrom: ${from}\nTo: ${to}`;
203
227
  if (cc)
204
228
  text += `\nCc: ${cc}`;
205
229
  text += `\nDate: ${date}`;
206
230
  text += `\n${'─'.repeat(60)}\n${bodyText}`;
207
- const attachments = meta.attachments;
231
+ const attachments = data.attachments;
208
232
  if (attachments && attachments.length > 0) {
209
233
  text += `\n${'─'.repeat(60)}\nAttachments:`;
210
234
  for (const att of attachments) {
@@ -227,6 +251,73 @@ const readMessageTool = {
227
251
  }
228
252
  },
229
253
  };
254
+ const searchMessagesTool = {
255
+ name: 'mail_search_messages',
256
+ description: 'Search email messages across all mailboxes by subject or sender. ' +
257
+ 'Returns matches with message IDs usable in mail_read_message. ' +
258
+ 'For threaded conversations all message IDs in the thread are returned.',
259
+ inputSchema: z.object({
260
+ query: z.string().describe('Search query (matches subject and sender name)'),
261
+ limit: z.number().min(1).max(20).optional().describe('Max results (default: 10)'),
262
+ }),
263
+ handler: async (args) => {
264
+ try {
265
+ const response = await fetchOCS('/ocs/v2.php/search/providers/mail/search', {
266
+ queryParams: {
267
+ term: args.query,
268
+ limit: String(args.limit ?? 10),
269
+ format: 'json',
270
+ },
271
+ });
272
+ const results = response.ocs.data;
273
+ if (!results.entries?.length) {
274
+ return {
275
+ content: [{ type: 'text', text: `No messages found for "${args.query}".` }],
276
+ };
277
+ }
278
+ const lines = await Promise.all(results.entries.map(async (entry) => {
279
+ const match = entry.resourceUrl?.match(/\/box\/(\d+)\/thread\/(\d+)/);
280
+ if (!match)
281
+ return `• ${entry.title}\n ${entry.subline}`;
282
+ const msgId = parseInt(match[2], 10);
283
+ try {
284
+ const threadResp = await fetchMailAPI(`/messages/${msgId}/thread`);
285
+ if (!threadResp.ok)
286
+ throw new Error('thread fetch failed');
287
+ const threadData = (await threadResp.json());
288
+ const msgs = (Array.isArray(threadData)
289
+ ? threadData
290
+ : (threadData.messages ?? []));
291
+ const sorted = msgs.sort((a, b) => (a.dateInt ?? 0) - (b.dateInt ?? 0));
292
+ if (sorted.length <= 1) {
293
+ return `• ${entry.title}\n ${entry.subline} | ID: ${msgId}`;
294
+ }
295
+ const idList = sorted.map((m) => (m.databaseId ?? m.id)).join(', ');
296
+ return `• ${entry.title}\n ${entry.subline} | Thread (${sorted.length} messages) IDs: ${idList}`;
297
+ }
298
+ catch {
299
+ return `• ${entry.title}\n ${entry.subline} | ID: ${msgId}`;
300
+ }
301
+ }));
302
+ let text = `Search results for "${args.query}" (${results.entries.length}):\n${lines.join('\n')}`;
303
+ if (results.isPaginated && results.cursor) {
304
+ text += `\n\nMore results available.`;
305
+ }
306
+ return { content: [{ type: 'text', text }] };
307
+ }
308
+ catch (error) {
309
+ return {
310
+ content: [
311
+ {
312
+ type: 'text',
313
+ text: `Error searching messages: ${error instanceof Error ? error.message : String(error)}`,
314
+ },
315
+ ],
316
+ isError: true,
317
+ };
318
+ }
319
+ },
320
+ };
230
321
  const sendMessageTool = {
231
322
  name: 'mail_send_message',
232
323
  description: 'Send an email message through Nextcloud Mail. Requires an account ID and recipient addresses.',
@@ -411,6 +502,7 @@ export const mailTools = [
411
502
  listMailboxesTool,
412
503
  listMessagesTool,
413
504
  readMessageTool,
505
+ searchMessagesTool,
414
506
  sendMessageTool,
415
507
  deleteMessageTool,
416
508
  moveMessageTool,
@@ -62,8 +62,15 @@ export const unifiedSearchTool = {
62
62
  content: [{ type: 'text', text: 'No search providers available.' }],
63
63
  };
64
64
  }
65
- // Sort by order and take top providers (max 5 to avoid flooding)
66
- const topProviders = [...providers].sort((a, b) => a.order - b.order).slice(0, 5);
65
+ // Always include the most useful providers; fill remaining slots by order.
66
+ // Without this, mail (order 20) and calendar (order 30) fall outside the
67
+ // top 5 on a typical install and are silently never searched.
68
+ const PRIORITY_IDS = ['files', 'mail', 'calendar', 'notes'];
69
+ const prioritized = PRIORITY_IDS.map((id) => providers.find((p) => p.id === id)).filter((p) => p !== undefined);
70
+ const rest = providers
71
+ .filter((p) => !PRIORITY_IDS.includes(p.id))
72
+ .sort((a, b) => a.order - b.order);
73
+ const topProviders = [...prioritized, ...rest].slice(0, 6);
67
74
  const searches = await Promise.allSettled(topProviders.map((p) => searchProvider(p.id, args.query, args.limit)));
68
75
  const output = [];
69
76
  for (const result of searches) {
@@ -7,6 +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
11
  import { loginHandler } from '../auth/login.js';
11
12
  import { logger } from '../logger.js';
12
13
  import { fetchStatus } from '../client/ocs.js';
@@ -176,6 +177,17 @@ export async function startHttp() {
176
177
  if (!registrationEnabled && !hasStaticClient) {
177
178
  logger.warn('No clients configured. Set MCP_CLIENT_ID or MCP_REGISTRATION_ENABLED=true');
178
179
  }
180
+ try {
181
+ probeStateDir();
182
+ }
183
+ catch (err) {
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);
188
+ }
189
+ throw err;
190
+ }
179
191
  const provider = new NextcloudOAuthProvider();
180
192
  // When gated dynamic registration is desired, require a bearer token on POST /register.
181
193
  if (registrationEnabled && registrationToken) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "AIquila - MCP server for Nextcloud integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",