aiquila-mcp 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AIquila MCP Server
2
2
 
3
- MCP (Model Context Protocol) server that gives any MCP client full access to your Nextcloud instance — files, calendar, tasks, contacts, mail, talk, maps, bookmarks, notes, polls, and more. 219 tools across 32 categories.
3
+ MCP (Model Context Protocol) server that gives any MCP client full access to your Nextcloud instance — files, calendar, tasks, contacts, mail, talk, maps, bookmarks, notes, polls, forms, and more. 244 tools across 33 categories.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -74,11 +74,12 @@ See the [Docker setup guide](https://github.com/elgorro/aiquila/blob/main/docs/m
74
74
  | Circles | 8 |
75
75
  | Bookmarks | 13 |
76
76
  | Polls | 21 |
77
+ | Forms | 25 |
77
78
  | Assistant | 4 |
78
79
  | Translate | 1 |
79
80
  | User Status | 5 |
80
81
  | Notifications | 4 |
81
- | **Total** | **219** |
82
+ | **Total** | **244** |
82
83
 
83
84
  ## Configuration
84
85
 
@@ -0,0 +1,51 @@
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 Forms REST API v3.
7
+ *
8
+ * Base path: /ocs/v2.php/apps/forms/api/v3
9
+ * Unwraps the OCS envelope and returns `ocs.data` directly.
10
+ * Throws {@link ApiError} on HTTP errors or non-success OCS status codes.
11
+ */
12
+ export async function fetchFormsAPI(endpoint, options = {}) {
13
+ const config = getNextcloudConfig();
14
+ const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
15
+ let url = `${config.url}/ocs/v2.php/apps/forms/api/v3${endpoint}`;
16
+ if (options.queryParams) {
17
+ const params = new URLSearchParams(options.queryParams);
18
+ url += `?${params.toString()}`;
19
+ }
20
+ const headers = {
21
+ Authorization: `Basic ${auth}`,
22
+ 'OCS-APIRequest': 'true',
23
+ Accept: 'application/json',
24
+ };
25
+ let body;
26
+ if (options.body !== undefined) {
27
+ body = JSON.stringify(options.body);
28
+ headers['Content-Type'] = 'application/json';
29
+ }
30
+ const method = options.method ?? 'GET';
31
+ const t0 = Date.now();
32
+ const response = await fetch(url, { method, headers, body });
33
+ logger.trace({ method, url, status: response.status, ms: Date.now() - t0 }, '[forms] HTTP');
34
+ if (!response.ok) {
35
+ const text = await response.text();
36
+ throw new ApiError(response.status, response.statusText, text);
37
+ }
38
+ if (response.status === 204) {
39
+ return undefined;
40
+ }
41
+ const contentType = response.headers.get('content-type') ?? '';
42
+ if (!contentType.includes('application/json')) {
43
+ return undefined;
44
+ }
45
+ const json = (await response.json());
46
+ const code = json?.ocs?.meta?.statuscode;
47
+ if (code !== undefined && code !== 200 && code !== 100) {
48
+ throw new ApiError(code, json.ocs.meta.status ?? 'error', json.ocs.meta.message ?? '');
49
+ }
50
+ return json.ocs.data;
51
+ }
@@ -0,0 +1,51 @@
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 Text app OCS API.
7
+ *
8
+ * Base path: /ocs/v2.php/apps/text
9
+ * Unwraps the OCS envelope and returns `ocs.data`.
10
+ * Throws {@link ApiError} on HTTP errors or non-success OCS status codes.
11
+ */
12
+ export async function fetchTextAPI(endpoint, options = {}) {
13
+ const config = getNextcloudConfig();
14
+ const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
15
+ let url = `${config.url}/ocs/v2.php/apps/text${endpoint}`;
16
+ if (options.queryParams) {
17
+ const params = new URLSearchParams(options.queryParams);
18
+ url += `?${params.toString()}`;
19
+ }
20
+ const headers = {
21
+ Authorization: `Basic ${auth}`,
22
+ 'OCS-APIRequest': 'true',
23
+ Accept: 'application/json',
24
+ };
25
+ let body;
26
+ if (options.body !== undefined) {
27
+ body = JSON.stringify(options.body);
28
+ headers['Content-Type'] = 'application/json';
29
+ }
30
+ const method = options.method ?? 'GET';
31
+ const t0 = Date.now();
32
+ const response = await fetch(url, { method, headers, body });
33
+ logger.trace({ method, url, status: response.status, ms: Date.now() - t0 }, '[text] HTTP');
34
+ if (!response.ok) {
35
+ const text = await response.text();
36
+ throw new ApiError(response.status, response.statusText, text);
37
+ }
38
+ if (response.status === 204) {
39
+ return undefined;
40
+ }
41
+ const contentType = response.headers.get('content-type') ?? '';
42
+ if (!contentType.includes('application/json')) {
43
+ return undefined;
44
+ }
45
+ const json = (await response.json());
46
+ const code = json?.ocs?.meta?.statuscode;
47
+ if (code !== undefined && code !== 200 && code !== 100) {
48
+ throw new ApiError(code, json.ocs.meta.status ?? 'error', json.ocs.meta.message ?? '');
49
+ }
50
+ return json.ocs.data;
51
+ }
@@ -35,6 +35,8 @@ import { trashTools } from './tools/apps/trash.js';
35
35
  import { versionsTools } from './tools/apps/versions.js';
36
36
  import { projectsTools } from './tools/apps/projects.js';
37
37
  import { pollsTools } from './tools/apps/polls.js';
38
+ import { formsTools } from './tools/apps/forms.js';
39
+ import { textTools } from './tools/apps/text.js';
38
40
  /**
39
41
  * Single source of truth for tool-to-Nextcloud-app mapping.
40
42
  *
@@ -74,6 +76,8 @@ export const TOOL_REGISTRY = [
74
76
  { category: 'circles', appIds: ['circles'], tools: circlesTools },
75
77
  { category: 'bookmarks', appIds: ['bookmarks'], tools: bookmarksTools },
76
78
  { category: 'polls', appIds: ['polls'], tools: pollsTools },
79
+ { category: 'forms', appIds: ['forms'], tools: formsTools },
80
+ { category: 'text', appIds: ['text'], tools: textTools },
77
81
  { category: 'assistant', appIds: ['assistant'], tools: assistantTools },
78
82
  { category: 'translate', appIds: ['text_translate', 'translate'], tools: translateTools },
79
83
  { category: 'user_status', appIds: ['user_status'], tools: userStatusTools },
@@ -0,0 +1,802 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchFormsAPI, } from '../../client/forms.js';
4
+ import { handleAppError } from '../error-utils.js';
5
+ /**
6
+ * Nextcloud Forms App Tools
7
+ * Uses the Forms OCS API v3 (/ocs/v2.php/apps/forms/api/v3)
8
+ */
9
+ const formsStatusMap = {
10
+ 400: 'Bad request — check parameters, or the form/question/option may not exist.',
11
+ 403: 'Access denied to this form.',
12
+ 404: 'Form, question, option, share, or submission not found.',
13
+ };
14
+ const QUESTION_TYPES = [
15
+ 'short',
16
+ 'long',
17
+ 'multiple',
18
+ 'multiple_unique',
19
+ 'dropdown',
20
+ 'date',
21
+ 'datetime',
22
+ 'time',
23
+ 'file',
24
+ 'linearscale',
25
+ 'color',
26
+ ];
27
+ const PERMISSIONS = ['edit', 'results', 'results_delete', 'submit', 'embed'];
28
+ const SHARE_TYPE_LABELS = {
29
+ 0: 'user',
30
+ 1: 'group',
31
+ 3: 'link',
32
+ };
33
+ const STATE_LABELS = {
34
+ 0: 'active',
35
+ 1: 'closed',
36
+ 2: 'archived',
37
+ };
38
+ function formatForm(f) {
39
+ const state = STATE_LABELS[f.state] ?? `state=${f.state}`;
40
+ const expires = f.expires ? ` expires=${new Date(f.expires * 1000).toISOString()}` : '';
41
+ const counts = f.submissionCount !== undefined ? ` submissions=${f.submissionCount}` : '';
42
+ return `[${f.id}] ${f.title || '(untitled)'} — owner=${f.ownerId}, ${state}${expires}${counts}`;
43
+ }
44
+ function formatFormDetailed(f) {
45
+ const lines = [formatForm(f)];
46
+ if (f.description)
47
+ lines.push(`Description: ${f.description}`);
48
+ if (f.hash)
49
+ lines.push(`Hash: ${f.hash}`);
50
+ if (f.created)
51
+ lines.push(`Created: ${new Date(f.created * 1000).toISOString()}`);
52
+ const flags = [
53
+ f.isAnonymous ? 'anonymous' : null,
54
+ f.submitMultiple ? 'submit-multiple' : null,
55
+ f.allowEditSubmissions ? 'edit-submissions' : null,
56
+ f.showExpiration ? 'show-expiration' : null,
57
+ f.access?.permitAllUsers ? 'all-users' : null,
58
+ ].filter(Boolean);
59
+ if (flags.length)
60
+ lines.push(`Flags: ${flags.join(', ')}`);
61
+ if (f.permissions?.length)
62
+ lines.push(`Permissions: ${f.permissions.join(', ')}`);
63
+ if (f.questions?.length)
64
+ lines.push(`Questions: ${f.questions.length}`);
65
+ if (f.shares?.length)
66
+ lines.push(`Shares: ${f.shares.length}`);
67
+ return lines.join('\n');
68
+ }
69
+ function formatQuestion(q) {
70
+ const req = q.isRequired ? ' *required*' : '';
71
+ const optCount = q.options?.length ? ` (${q.options.length} options)` : '';
72
+ return `[${q.id}] #${q.order} ${q.type}${req}: ${q.text || q.name || '(no text)'}${optCount}`;
73
+ }
74
+ function formatOption(o) {
75
+ return `[${o.id}] #${o.order} ${o.text}`;
76
+ }
77
+ function formatShare(s) {
78
+ const type = SHARE_TYPE_LABELS[s.shareType] ?? `type=${s.shareType}`;
79
+ const who = s.displayName ? `${s.displayName} (${s.shareWith})` : s.shareWith;
80
+ const perms = s.permissions?.length ? ` [${s.permissions.join(',')}]` : '';
81
+ return `[${s.id}] ${type}: ${who}${perms}`;
82
+ }
83
+ function formatAnswer(a) {
84
+ const q = a.questionName ? `${a.questionName}: ` : '';
85
+ const file = a.fileId ? ` (fileId=${a.fileId})` : '';
86
+ return `${q}${a.text}${file}`;
87
+ }
88
+ function formatSubmission(s) {
89
+ const when = s.timestamp ? new Date(s.timestamp * 1000).toISOString() : '';
90
+ const who = s.userDisplayName || s.userId || 'anonymous';
91
+ const answers = s.answers && s.answers.length ? '\n ' + s.answers.map(formatAnswer).join('\n ') : '';
92
+ return `[${s.id}] ${who}${when ? ` at ${when}` : ''}${answers}`;
93
+ }
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Forms
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ export const listFormsTool = {
98
+ name: 'list_forms',
99
+ description: 'List forms. type="owned" (default) returns forms the current user owns; "shared" returns forms shared with them; "partial" returns forms the user has an unfinished draft on.',
100
+ inputSchema: z.object({
101
+ type: z.enum(['owned', 'shared', 'partial']).optional().describe('Form list scope'),
102
+ }),
103
+ handler: async (args = {}) => {
104
+ try {
105
+ const queryParams = args.type ? { type: args.type } : undefined;
106
+ const forms = await fetchFormsAPI('/forms', { queryParams });
107
+ if (!forms || forms.length === 0) {
108
+ return { content: [{ type: 'text', text: 'No forms found.' }] };
109
+ }
110
+ return {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: `Forms (${forms.length}):\n\n${forms.map(formatForm).join('\n')}`,
115
+ },
116
+ ],
117
+ };
118
+ }
119
+ catch (error) {
120
+ return handleAppError(error, 'Error listing forms', formsStatusMap);
121
+ }
122
+ },
123
+ };
124
+ export const getFormTool = {
125
+ name: 'get_form',
126
+ description: 'Get full details for a form by ID, including questions, options, shares, and metadata.',
127
+ inputSchema: z.object({
128
+ formId: z.number().int().describe('Form ID (from list_forms)'),
129
+ }),
130
+ handler: async (args) => {
131
+ try {
132
+ const form = await fetchFormsAPI(`/forms/${args.formId}`);
133
+ return {
134
+ content: [{ type: 'text', text: formatFormDetailed(form) }],
135
+ };
136
+ }
137
+ catch (error) {
138
+ return handleAppError(error, 'Error getting form', formsStatusMap);
139
+ }
140
+ },
141
+ };
142
+ export const createFormTool = {
143
+ name: 'create_form',
144
+ description: 'Create a new empty form. Returns the new form ID. Use update_form to set title/description and create_form_question to add questions.',
145
+ inputSchema: z.object({}),
146
+ handler: async () => {
147
+ try {
148
+ const form = await fetchFormsAPI('/forms', { method: 'POST', body: {} });
149
+ return {
150
+ content: [{ type: 'text', text: `Form created: ${formatForm(form)}` }],
151
+ };
152
+ }
153
+ catch (error) {
154
+ return handleAppError(error, 'Error creating form', formsStatusMap);
155
+ }
156
+ },
157
+ };
158
+ export const cloneFormTool = {
159
+ name: 'clone_form',
160
+ description: 'Clone an existing form (its questions, options, and settings) into a new form.',
161
+ inputSchema: z.object({
162
+ formId: z.number().int().describe('Form ID to clone'),
163
+ }),
164
+ handler: async (args) => {
165
+ try {
166
+ const form = await fetchFormsAPI('/forms', {
167
+ method: 'POST',
168
+ body: {},
169
+ queryParams: { fromId: String(args.formId) },
170
+ });
171
+ return {
172
+ content: [{ type: 'text', text: `Form cloned: ${formatForm(form)}` }],
173
+ };
174
+ }
175
+ catch (error) {
176
+ return handleAppError(error, 'Error cloning form', formsStatusMap);
177
+ }
178
+ },
179
+ };
180
+ export const updateFormTool = {
181
+ name: 'update_form',
182
+ description: 'Update form properties. Only provided fields are changed. state: 0=active, 1=closed, 2=archived. expires=0 disables expiration.',
183
+ inputSchema: z.object({
184
+ formId: z.number().int().describe('Form ID'),
185
+ title: z.string().optional(),
186
+ description: z.string().optional(),
187
+ submissionMessage: z.string().optional(),
188
+ expires: z.number().int().optional().describe('Unix timestamp; 0 = no expiration'),
189
+ isAnonymous: z.boolean().optional(),
190
+ submitMultiple: z.boolean().optional(),
191
+ allowEditSubmissions: z.boolean().optional(),
192
+ showExpiration: z.boolean().optional(),
193
+ state: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
194
+ }),
195
+ handler: async (args) => {
196
+ try {
197
+ const { formId, ...rest } = args;
198
+ const body = {};
199
+ for (const [k, v] of Object.entries(rest)) {
200
+ if (v !== undefined)
201
+ body[k] = v;
202
+ }
203
+ if (Object.keys(body).length === 0) {
204
+ return {
205
+ content: [{ type: 'text', text: 'No fields provided to update.' }],
206
+ isError: true,
207
+ };
208
+ }
209
+ const form = await fetchFormsAPI(`/forms/${formId}`, {
210
+ method: 'PATCH',
211
+ body,
212
+ });
213
+ return {
214
+ content: [{ type: 'text', text: `Form updated: ${formatForm(form)}` }],
215
+ };
216
+ }
217
+ catch (error) {
218
+ return handleAppError(error, 'Error updating form', formsStatusMap);
219
+ }
220
+ },
221
+ };
222
+ export const transferFormOwnerTool = {
223
+ name: 'transfer_form_owner',
224
+ description: 'Transfer ownership of a form to another Nextcloud user. The new owner must have access to the Forms app.',
225
+ inputSchema: z.object({
226
+ formId: z.number().int().describe('Form ID'),
227
+ ownerId: z.string().describe('User ID of the new owner'),
228
+ }),
229
+ handler: async (args) => {
230
+ try {
231
+ const form = await fetchFormsAPI(`/forms/${args.formId}`, {
232
+ method: 'PATCH',
233
+ body: { ownerId: args.ownerId },
234
+ });
235
+ return {
236
+ content: [{ type: 'text', text: `Ownership transferred: ${formatForm(form)}` }],
237
+ };
238
+ }
239
+ catch (error) {
240
+ return handleAppError(error, 'Error transferring form ownership', formsStatusMap);
241
+ }
242
+ },
243
+ };
244
+ export const deleteFormTool = {
245
+ name: 'delete_form',
246
+ description: 'Permanently delete a form and all of its submissions. This cannot be undone.',
247
+ inputSchema: z.object({
248
+ formId: z.number().int().describe('Form ID'),
249
+ }),
250
+ handler: async (args) => {
251
+ try {
252
+ await fetchFormsAPI(`/forms/${args.formId}`, { method: 'DELETE' });
253
+ return {
254
+ content: [{ type: 'text', text: `Form ${args.formId} deleted.` }],
255
+ };
256
+ }
257
+ catch (error) {
258
+ return handleAppError(error, 'Error deleting form', formsStatusMap);
259
+ }
260
+ },
261
+ };
262
+ // ─────────────────────────────────────────────────────────────────────────────
263
+ // Questions
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+ export const listFormQuestionsTool = {
266
+ name: 'list_form_questions',
267
+ description: 'List all questions for a form, in display order.',
268
+ inputSchema: z.object({
269
+ formId: z.number().int().describe('Form ID'),
270
+ }),
271
+ handler: async (args) => {
272
+ try {
273
+ const questions = await fetchFormsAPI(`/forms/${args.formId}/questions`);
274
+ if (!questions || questions.length === 0) {
275
+ return { content: [{ type: 'text', text: 'No questions for this form.' }] };
276
+ }
277
+ return {
278
+ content: [
279
+ {
280
+ type: 'text',
281
+ text: `Questions (${questions.length}):\n\n${questions.map(formatQuestion).join('\n')}`,
282
+ },
283
+ ],
284
+ };
285
+ }
286
+ catch (error) {
287
+ return handleAppError(error, 'Error listing questions', formsStatusMap);
288
+ }
289
+ },
290
+ };
291
+ export const createFormQuestionTool = {
292
+ name: 'create_form_question',
293
+ description: 'Add a question to a form. For choice questions use type "multiple" (checkbox), "multiple_unique" (radio), or "dropdown" — then call create_form_options.',
294
+ inputSchema: z.object({
295
+ formId: z.number().int().describe('Form ID'),
296
+ type: z.enum(QUESTION_TYPES).describe('Question type'),
297
+ text: z.string().optional().describe('Question text (optional at creation)'),
298
+ }),
299
+ handler: async (args) => {
300
+ try {
301
+ const body = { type: args.type };
302
+ if (args.text !== undefined)
303
+ body.text = args.text;
304
+ const question = await fetchFormsAPI(`/forms/${args.formId}/questions`, {
305
+ method: 'POST',
306
+ body,
307
+ });
308
+ return {
309
+ content: [{ type: 'text', text: `Question created: ${formatQuestion(question)}` }],
310
+ };
311
+ }
312
+ catch (error) {
313
+ return handleAppError(error, 'Error creating question', formsStatusMap);
314
+ }
315
+ },
316
+ };
317
+ export const updateFormQuestionTool = {
318
+ name: 'update_form_question',
319
+ description: 'Update question properties (text, requirement, type, etc.). Only provided fields are changed.',
320
+ inputSchema: z.object({
321
+ formId: z.number().int().describe('Form ID'),
322
+ questionId: z.number().int().describe('Question ID'),
323
+ text: z.string().optional(),
324
+ name: z.string().optional().describe('Internal name / shortcode'),
325
+ isRequired: z.boolean().optional(),
326
+ type: z.enum(QUESTION_TYPES).optional(),
327
+ extraSettings: z
328
+ .record(z.string(), z.unknown())
329
+ .optional()
330
+ .describe('Question-type-specific settings (e.g. validationRegex, allowedFileTypes)'),
331
+ }),
332
+ handler: async (args) => {
333
+ try {
334
+ const { formId, questionId, ...rest } = args;
335
+ const body = {};
336
+ for (const [k, v] of Object.entries(rest)) {
337
+ if (v !== undefined)
338
+ body[k] = v;
339
+ }
340
+ if (Object.keys(body).length === 0) {
341
+ return {
342
+ content: [{ type: 'text', text: 'No fields provided to update.' }],
343
+ isError: true,
344
+ };
345
+ }
346
+ const question = await fetchFormsAPI(`/forms/${formId}/questions/${questionId}`, { method: 'PATCH', body });
347
+ return {
348
+ content: [{ type: 'text', text: `Question updated: ${formatQuestion(question)}` }],
349
+ };
350
+ }
351
+ catch (error) {
352
+ return handleAppError(error, 'Error updating question', formsStatusMap);
353
+ }
354
+ },
355
+ };
356
+ export const reorderFormQuestionsTool = {
357
+ name: 'reorder_form_questions',
358
+ description: 'Reorder all questions in a form. Pass the full list of question IDs in the desired order.',
359
+ inputSchema: z.object({
360
+ formId: z.number().int().describe('Form ID'),
361
+ newOrder: z
362
+ .array(z.number().int())
363
+ .min(1)
364
+ .describe('Full list of question IDs in desired order'),
365
+ }),
366
+ handler: async (args) => {
367
+ try {
368
+ await fetchFormsAPI(`/forms/${args.formId}/questions`, {
369
+ method: 'PATCH',
370
+ body: { newOrder: args.newOrder },
371
+ });
372
+ return {
373
+ content: [{ type: 'text', text: `Reordered ${args.newOrder.length} questions.` }],
374
+ };
375
+ }
376
+ catch (error) {
377
+ return handleAppError(error, 'Error reordering questions', formsStatusMap);
378
+ }
379
+ },
380
+ };
381
+ export const deleteFormQuestionTool = {
382
+ name: 'delete_form_question',
383
+ description: 'Delete a question from a form.',
384
+ inputSchema: z.object({
385
+ formId: z.number().int().describe('Form ID'),
386
+ questionId: z.number().int().describe('Question ID'),
387
+ }),
388
+ handler: async (args) => {
389
+ try {
390
+ await fetchFormsAPI(`/forms/${args.formId}/questions/${args.questionId}`, {
391
+ method: 'DELETE',
392
+ });
393
+ return {
394
+ content: [{ type: 'text', text: `Question ${args.questionId} deleted.` }],
395
+ };
396
+ }
397
+ catch (error) {
398
+ return handleAppError(error, 'Error deleting question', formsStatusMap);
399
+ }
400
+ },
401
+ };
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+ // Options
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+ export const createFormOptionsTool = {
406
+ name: 'create_form_options',
407
+ description: 'Add one or more options to a choice question (multiple/multiple_unique/dropdown). Pass an array of option texts.',
408
+ inputSchema: z.object({
409
+ formId: z.number().int().describe('Form ID'),
410
+ questionId: z.number().int().describe('Question ID'),
411
+ optionTexts: z.array(z.string().min(1)).min(1).describe('One or more option texts to create'),
412
+ }),
413
+ handler: async (args) => {
414
+ try {
415
+ const options = await fetchFormsAPI(`/forms/${args.formId}/questions/${args.questionId}/options`, { method: 'POST', body: { optionTexts: args.optionTexts } });
416
+ const list = options && options.length ? options.map(formatOption).join('\n') : '(none)';
417
+ return {
418
+ content: [
419
+ {
420
+ type: 'text',
421
+ text: `Options created (${options?.length ?? 0}):\n${list}`,
422
+ },
423
+ ],
424
+ };
425
+ }
426
+ catch (error) {
427
+ return handleAppError(error, 'Error creating options', formsStatusMap);
428
+ }
429
+ },
430
+ };
431
+ export const updateFormOptionTool = {
432
+ name: 'update_form_option',
433
+ description: "Update a choice option's text (and optionally its order).",
434
+ inputSchema: z.object({
435
+ formId: z.number().int().describe('Form ID'),
436
+ questionId: z.number().int().describe('Question ID'),
437
+ optionId: z.number().int().describe('Option ID'),
438
+ text: z.string().optional(),
439
+ order: z.number().int().optional(),
440
+ }),
441
+ handler: async (args) => {
442
+ try {
443
+ const { formId, questionId, optionId, ...rest } = args;
444
+ const body = {};
445
+ for (const [k, v] of Object.entries(rest)) {
446
+ if (v !== undefined)
447
+ body[k] = v;
448
+ }
449
+ if (Object.keys(body).length === 0) {
450
+ return {
451
+ content: [{ type: 'text', text: 'No fields provided to update.' }],
452
+ isError: true,
453
+ };
454
+ }
455
+ const option = await fetchFormsAPI(`/forms/${formId}/questions/${questionId}/options/${optionId}`, { method: 'PATCH', body });
456
+ return {
457
+ content: [{ type: 'text', text: `Option updated: ${formatOption(option)}` }],
458
+ };
459
+ }
460
+ catch (error) {
461
+ return handleAppError(error, 'Error updating option', formsStatusMap);
462
+ }
463
+ },
464
+ };
465
+ export const reorderFormOptionsTool = {
466
+ name: 'reorder_form_options',
467
+ description: 'Reorder all options for a choice question. Pass the full list of option IDs in the desired order.',
468
+ inputSchema: z.object({
469
+ formId: z.number().int().describe('Form ID'),
470
+ questionId: z.number().int().describe('Question ID'),
471
+ newOrder: z.array(z.number().int()).min(1).describe('Full list of option IDs in desired order'),
472
+ }),
473
+ handler: async (args) => {
474
+ try {
475
+ await fetchFormsAPI(`/forms/${args.formId}/questions/${args.questionId}/options/reorder`, {
476
+ method: 'PATCH',
477
+ body: { newOrder: args.newOrder },
478
+ });
479
+ return {
480
+ content: [{ type: 'text', text: `Reordered ${args.newOrder.length} options.` }],
481
+ };
482
+ }
483
+ catch (error) {
484
+ return handleAppError(error, 'Error reordering options', formsStatusMap);
485
+ }
486
+ },
487
+ };
488
+ export const deleteFormOptionTool = {
489
+ name: 'delete_form_option',
490
+ description: 'Delete an option from a choice question.',
491
+ inputSchema: z.object({
492
+ formId: z.number().int().describe('Form ID'),
493
+ questionId: z.number().int().describe('Question ID'),
494
+ optionId: z.number().int().describe('Option ID'),
495
+ }),
496
+ handler: async (args) => {
497
+ try {
498
+ await fetchFormsAPI(`/forms/${args.formId}/questions/${args.questionId}/options/${args.optionId}`, { method: 'DELETE' });
499
+ return {
500
+ content: [{ type: 'text', text: `Option ${args.optionId} deleted.` }],
501
+ };
502
+ }
503
+ catch (error) {
504
+ return handleAppError(error, 'Error deleting option', formsStatusMap);
505
+ }
506
+ },
507
+ };
508
+ // ─────────────────────────────────────────────────────────────────────────────
509
+ // Shares
510
+ // ─────────────────────────────────────────────────────────────────────────────
511
+ export const createFormShareTool = {
512
+ name: 'create_form_share',
513
+ description: 'Share a form. type="user" shares with a Nextcloud user (shareWith=userId), "group" with a group (shareWith=groupId), "link" creates a public link (shareWith is the generated hash, omit it at creation).',
514
+ inputSchema: z.object({
515
+ formId: z.number().int().describe('Form ID'),
516
+ type: z.enum(['user', 'group', 'link']).describe('Share type'),
517
+ shareWith: z
518
+ .string()
519
+ .optional()
520
+ .describe('User ID (type=user) or group ID (type=group). Omit for type=link.'),
521
+ permissions: z
522
+ .array(z.enum(PERMISSIONS))
523
+ .optional()
524
+ .describe('Permissions to grant. Defaults to ["submit"] if omitted.'),
525
+ }),
526
+ handler: async (args) => {
527
+ try {
528
+ const shareTypeMap = {
529
+ user: 0,
530
+ group: 1,
531
+ link: 3,
532
+ };
533
+ const shareTypeValue = shareTypeMap[args.type];
534
+ if ((args.type === 'user' || args.type === 'group') && !args.shareWith) {
535
+ return {
536
+ content: [
537
+ {
538
+ type: 'text',
539
+ text: `shareWith is required for type="${args.type}".`,
540
+ },
541
+ ],
542
+ isError: true,
543
+ };
544
+ }
545
+ const body = {
546
+ shareType: String(shareTypeValue),
547
+ shareWith: args.shareWith ?? '',
548
+ permissions: args.permissions ?? ['submit'],
549
+ };
550
+ const share = await fetchFormsAPI(`/forms/${args.formId}/shares`, {
551
+ method: 'POST',
552
+ body,
553
+ });
554
+ return {
555
+ content: [{ type: 'text', text: `Share created: ${formatShare(share)}` }],
556
+ };
557
+ }
558
+ catch (error) {
559
+ return handleAppError(error, 'Error creating share', formsStatusMap);
560
+ }
561
+ },
562
+ };
563
+ export const updateFormShareTool = {
564
+ name: 'update_form_share',
565
+ description: 'Update the permissions on an existing form share.',
566
+ inputSchema: z.object({
567
+ formId: z.number().int().describe('Form ID'),
568
+ shareId: z.number().int().describe('Share ID (from get_form)'),
569
+ permissions: z.array(z.enum(PERMISSIONS)).min(1).describe('New permission set'),
570
+ }),
571
+ handler: async (args) => {
572
+ try {
573
+ const share = await fetchFormsAPI(`/forms/${args.formId}/shares/${args.shareId}`, {
574
+ method: 'PATCH',
575
+ body: { permissions: args.permissions },
576
+ });
577
+ return {
578
+ content: [{ type: 'text', text: `Share updated: ${formatShare(share)}` }],
579
+ };
580
+ }
581
+ catch (error) {
582
+ return handleAppError(error, 'Error updating share', formsStatusMap);
583
+ }
584
+ },
585
+ };
586
+ export const deleteFormShareTool = {
587
+ name: 'delete_form_share',
588
+ description: 'Revoke a form share.',
589
+ inputSchema: z.object({
590
+ formId: z.number().int().describe('Form ID'),
591
+ shareId: z.number().int().describe('Share ID'),
592
+ }),
593
+ handler: async (args) => {
594
+ try {
595
+ await fetchFormsAPI(`/forms/${args.formId}/shares/${args.shareId}`, {
596
+ method: 'DELETE',
597
+ });
598
+ return {
599
+ content: [{ type: 'text', text: `Share ${args.shareId} deleted.` }],
600
+ };
601
+ }
602
+ catch (error) {
603
+ return handleAppError(error, 'Error deleting share', formsStatusMap);
604
+ }
605
+ },
606
+ };
607
+ // ─────────────────────────────────────────────────────────────────────────────
608
+ // Submissions
609
+ // ─────────────────────────────────────────────────────────────────────────────
610
+ export const listFormSubmissionsTool = {
611
+ name: 'list_form_submissions',
612
+ description: 'List submissions for a form. Supports free-text search (query) and pagination (limit/offset). Only form owners/admins see all submissions.',
613
+ inputSchema: z.object({
614
+ formId: z.number().int().describe('Form ID'),
615
+ query: z.string().optional().describe('Free-text search across answer values'),
616
+ limit: z.number().int().min(1).max(500).optional().describe('Max submissions to return'),
617
+ offset: z.number().int().min(0).optional().describe('Offset for pagination'),
618
+ }),
619
+ handler: async (args) => {
620
+ try {
621
+ const queryParams = {};
622
+ if (args.query)
623
+ queryParams.query = args.query;
624
+ if (args.limit !== undefined)
625
+ queryParams.limit = String(args.limit);
626
+ if (args.offset !== undefined)
627
+ queryParams.offset = String(args.offset);
628
+ const data = await fetchFormsAPI(`/forms/${args.formId}/submissions`, {
629
+ queryParams: Object.keys(queryParams).length ? queryParams : undefined,
630
+ });
631
+ const subs = data?.submissions ?? [];
632
+ if (!subs.length) {
633
+ return { content: [{ type: 'text', text: 'No submissions for this form.' }] };
634
+ }
635
+ return {
636
+ content: [
637
+ {
638
+ type: 'text',
639
+ text: `Submissions (${subs.length}):\n\n${subs.map(formatSubmission).join('\n\n')}`,
640
+ },
641
+ ],
642
+ };
643
+ }
644
+ catch (error) {
645
+ return handleAppError(error, 'Error listing submissions', formsStatusMap);
646
+ }
647
+ },
648
+ };
649
+ export const getFormSubmissionTool = {
650
+ name: 'get_form_submission',
651
+ description: 'Get a single submission with all of its answers.',
652
+ inputSchema: z.object({
653
+ formId: z.number().int().describe('Form ID'),
654
+ submissionId: z.number().int().describe('Submission ID'),
655
+ }),
656
+ handler: async (args) => {
657
+ try {
658
+ const submission = await fetchFormsAPI(`/forms/${args.formId}/submissions/${args.submissionId}`);
659
+ return {
660
+ content: [{ type: 'text', text: formatSubmission(submission) }],
661
+ };
662
+ }
663
+ catch (error) {
664
+ return handleAppError(error, 'Error getting submission', formsStatusMap);
665
+ }
666
+ },
667
+ };
668
+ export const createFormSubmissionTool = {
669
+ name: 'create_form_submission',
670
+ description: 'Submit answers to a form. `answers` is an object keyed by question ID; values are always arrays. For text questions: ["My answer"]. For choice questions: [optionId1, optionId2] (numeric IDs). Public links require `shareHash`.',
671
+ inputSchema: z.object({
672
+ formId: z.number().int().describe('Form ID'),
673
+ answers: z
674
+ .record(z.string(), z.array(z.union([z.string(), z.number()])))
675
+ .describe('Object keyed by question ID; each value is an array of strings or option IDs'),
676
+ shareHash: z
677
+ .string()
678
+ .optional()
679
+ .describe('Share hash — required when submitting via a public link'),
680
+ }),
681
+ handler: async (args) => {
682
+ try {
683
+ const body = { answers: args.answers };
684
+ if (args.shareHash)
685
+ body.shareHash = args.shareHash;
686
+ await fetchFormsAPI(`/forms/${args.formId}/submissions`, {
687
+ method: 'POST',
688
+ body,
689
+ });
690
+ return {
691
+ content: [{ type: 'text', text: `Submission recorded for form ${args.formId}.` }],
692
+ };
693
+ }
694
+ catch (error) {
695
+ return handleAppError(error, 'Error creating submission', formsStatusMap);
696
+ }
697
+ },
698
+ };
699
+ export const deleteFormSubmissionTool = {
700
+ name: 'delete_form_submission',
701
+ description: 'Delete a single submission by ID.',
702
+ inputSchema: z.object({
703
+ formId: z.number().int().describe('Form ID'),
704
+ submissionId: z.number().int().describe('Submission ID'),
705
+ }),
706
+ handler: async (args) => {
707
+ try {
708
+ await fetchFormsAPI(`/forms/${args.formId}/submissions/${args.submissionId}`, {
709
+ method: 'DELETE',
710
+ });
711
+ return {
712
+ content: [{ type: 'text', text: `Submission ${args.submissionId} deleted.` }],
713
+ };
714
+ }
715
+ catch (error) {
716
+ return handleAppError(error, 'Error deleting submission', formsStatusMap);
717
+ }
718
+ },
719
+ };
720
+ export const deleteAllFormSubmissionsTool = {
721
+ name: 'delete_all_form_submissions',
722
+ description: 'Delete every submission for a form. The form itself is kept. This cannot be undone.',
723
+ inputSchema: z.object({
724
+ formId: z.number().int().describe('Form ID'),
725
+ }),
726
+ handler: async (args) => {
727
+ try {
728
+ await fetchFormsAPI(`/forms/${args.formId}/submissions`, { method: 'DELETE' });
729
+ return {
730
+ content: [
731
+ {
732
+ type: 'text',
733
+ text: `All submissions deleted for form ${args.formId}.`,
734
+ },
735
+ ],
736
+ };
737
+ }
738
+ catch (error) {
739
+ return handleAppError(error, 'Error deleting submissions', formsStatusMap);
740
+ }
741
+ },
742
+ };
743
+ export const exportFormSubmissionsTool = {
744
+ name: 'export_form_submissions',
745
+ description: "Export a form's submissions as a spreadsheet into the user's Nextcloud storage. Returns the destination path. Use read_file / get_file_info afterwards to work with the export.",
746
+ inputSchema: z.object({
747
+ formId: z.number().int().describe('Form ID'),
748
+ path: z.string().describe('Target folder path inside the user\'s Nextcloud (e.g. "/Exports")'),
749
+ fileFormat: z.enum(['csv', 'ods', 'xlsx']).describe('Export file format'),
750
+ }),
751
+ handler: async (args) => {
752
+ try {
753
+ const result = await fetchFormsAPI(`/forms/${args.formId}/submissions/export`, {
754
+ method: 'POST',
755
+ body: { path: args.path, fileFormat: args.fileFormat },
756
+ });
757
+ const where = typeof result === 'string'
758
+ ? result
759
+ : (result?.path ??
760
+ result?.fileName ??
761
+ `${args.path}/form-${args.formId}.${args.fileFormat}`);
762
+ return {
763
+ content: [{ type: 'text', text: `Submissions exported to ${where}.` }],
764
+ };
765
+ }
766
+ catch (error) {
767
+ return handleAppError(error, 'Error exporting submissions', formsStatusMap);
768
+ }
769
+ },
770
+ };
771
+ export const formsTools = [
772
+ // Forms
773
+ listFormsTool,
774
+ getFormTool,
775
+ createFormTool,
776
+ cloneFormTool,
777
+ updateFormTool,
778
+ transferFormOwnerTool,
779
+ deleteFormTool,
780
+ // Questions
781
+ listFormQuestionsTool,
782
+ createFormQuestionTool,
783
+ updateFormQuestionTool,
784
+ reorderFormQuestionsTool,
785
+ deleteFormQuestionTool,
786
+ // Options
787
+ createFormOptionsTool,
788
+ updateFormOptionTool,
789
+ reorderFormOptionsTool,
790
+ deleteFormOptionTool,
791
+ // Shares
792
+ createFormShareTool,
793
+ updateFormShareTool,
794
+ deleteFormShareTool,
795
+ // Submissions
796
+ listFormSubmissionsTool,
797
+ getFormSubmissionTool,
798
+ createFormSubmissionTool,
799
+ deleteFormSubmissionTool,
800
+ deleteAllFormSubmissionsTool,
801
+ exportFormSubmissionsTool,
802
+ ];
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchTextAPI, } from '../../client/text.js';
4
+ import { ApiError } from '../../client/aiquila.js';
5
+ import { getWebDAVClient } from '../../client/webdav.js';
6
+ import { handleAppError } from '../error-utils.js';
7
+ /**
8
+ * Nextcloud Text App Tools — workspaces (per-folder Readme.md) and direct-edit URLs.
9
+ *
10
+ * Uses the Text OCS API (/ocs/v2.php/apps/text/workspace) plus WebDAV for content I/O.
11
+ * Collaborative editing sessions are intentionally out of scope: the live editor runs
12
+ * in the user's browser via the direct-edit URL.
13
+ */
14
+ const textStatusMap = {
15
+ 400: 'Bad request — check the folder path.',
16
+ 403: 'Access denied to this folder.',
17
+ 404: 'No workspace exists for this folder.',
18
+ };
19
+ const FolderPathArg = z
20
+ .string()
21
+ .describe("Folder path relative to the user's root (e.g. '/Projects/Acme')");
22
+ function normaliseFolder(path) {
23
+ if (!path || path === '/')
24
+ return '/';
25
+ return path.replace(/\/+$/, '');
26
+ }
27
+ function joinPath(folder, name) {
28
+ const f = normaliseFolder(folder);
29
+ return f === '/' ? `/${name}` : `${f}/${name}`;
30
+ }
31
+ function formatWorkspace(file) {
32
+ return `[${file.id}] ${file.name} (${file.mimetype}) at ${file.path}`;
33
+ }
34
+ async function resolveWorkspaceFile(folder) {
35
+ try {
36
+ const data = await fetchTextAPI('/workspace', {
37
+ queryParams: { path: folder },
38
+ });
39
+ return data?.file ?? null;
40
+ }
41
+ catch (error) {
42
+ if (error instanceof ApiError && error.statusCode === 404) {
43
+ return null;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ export const getTextWorkspaceTool = {
49
+ name: 'get_text_workspace',
50
+ description: "Get metadata of the Text workspace file (Readme.md) for a folder. Returns the file's id, name, mimetype and path, or reports that no workspace exists yet.",
51
+ inputSchema: z.object({ path: FolderPathArg }),
52
+ handler: async (args) => {
53
+ try {
54
+ const file = await resolveWorkspaceFile(args.path);
55
+ if (!file) {
56
+ return {
57
+ content: [
58
+ {
59
+ type: 'text',
60
+ text: `No workspace file in ${normaliseFolder(args.path) || '/'}.`,
61
+ },
62
+ ],
63
+ };
64
+ }
65
+ return {
66
+ content: [{ type: 'text', text: `Workspace: ${formatWorkspace(file)}` }],
67
+ };
68
+ }
69
+ catch (error) {
70
+ return handleAppError(error, 'Error getting workspace', textStatusMap);
71
+ }
72
+ },
73
+ };
74
+ export const readTextWorkspaceTool = {
75
+ name: 'read_text_workspace',
76
+ description: "Read the content of a folder's Text workspace file (Readme.md). Returns markdown text, or a 'no workspace' message if none exists.",
77
+ inputSchema: z.object({ path: FolderPathArg }),
78
+ handler: async (args) => {
79
+ try {
80
+ const file = await resolveWorkspaceFile(args.path);
81
+ if (!file) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text: `No workspace file in ${normaliseFolder(args.path) || '/'}.`,
87
+ },
88
+ ],
89
+ };
90
+ }
91
+ const client = getWebDAVClient();
92
+ const content = (await client.getFileContents(file.path, { format: 'text' }));
93
+ return { content: [{ type: 'text', text: content }] };
94
+ }
95
+ catch (error) {
96
+ return handleAppError(error, 'Error reading workspace', textStatusMap);
97
+ }
98
+ },
99
+ };
100
+ export const writeTextWorkspaceTool = {
101
+ name: 'write_text_workspace',
102
+ description: "Create or overwrite a folder's Text workspace file. If a workspace already exists, its existing filename is reused; otherwise Readme.md is created at the folder root.",
103
+ inputSchema: z.object({
104
+ path: FolderPathArg,
105
+ content: z.string().describe('Markdown content to write'),
106
+ }),
107
+ handler: async (args) => {
108
+ try {
109
+ const existing = await resolveWorkspaceFile(args.path);
110
+ const targetPath = existing?.path ?? joinPath(args.path, 'Readme.md');
111
+ const client = getWebDAVClient();
112
+ await client.putFileContents(targetPath, args.content, { overwrite: true });
113
+ return {
114
+ content: [
115
+ {
116
+ type: 'text',
117
+ text: existing
118
+ ? `Workspace updated at ${targetPath}.`
119
+ : `Workspace created at ${targetPath}.`,
120
+ },
121
+ ],
122
+ };
123
+ }
124
+ catch (error) {
125
+ return handleAppError(error, 'Error writing workspace', textStatusMap);
126
+ }
127
+ },
128
+ };
129
+ export const deleteTextWorkspaceTool = {
130
+ name: 'delete_text_workspace',
131
+ description: 'Delete the Text workspace file (Readme.md) for a folder. The folder itself is kept. No-op if the folder has no workspace.',
132
+ inputSchema: z.object({ path: FolderPathArg }),
133
+ handler: async (args) => {
134
+ try {
135
+ const existing = await resolveWorkspaceFile(args.path);
136
+ if (!existing) {
137
+ return {
138
+ content: [
139
+ {
140
+ type: 'text',
141
+ text: `No workspace file in ${normaliseFolder(args.path) || '/'}.`,
142
+ },
143
+ ],
144
+ };
145
+ }
146
+ const client = getWebDAVClient();
147
+ await client.deleteFile(existing.path);
148
+ return {
149
+ content: [{ type: 'text', text: `Workspace deleted at ${existing.path}.` }],
150
+ };
151
+ }
152
+ catch (error) {
153
+ return handleAppError(error, 'Error deleting workspace', textStatusMap);
154
+ }
155
+ },
156
+ };
157
+ export const getTextWorkspaceEditUrlTool = {
158
+ name: 'get_text_workspace_edit_url',
159
+ description: "Get a one-shot direct-edit URL for a folder's Text workspace. Opens the live collaborative editor in a browser. The Readme.md is created automatically if it does not exist yet. Hand the URL to a human collaborator — the MCP server does not participate in the editing session.",
160
+ inputSchema: z.object({ path: FolderPathArg }),
161
+ handler: async (args) => {
162
+ try {
163
+ const data = await fetchTextAPI('/workspace/direct', {
164
+ method: 'POST',
165
+ body: { path: args.path },
166
+ });
167
+ return { content: [{ type: 'text', text: data.url }] };
168
+ }
169
+ catch (error) {
170
+ return handleAppError(error, 'Error getting workspace edit URL', textStatusMap);
171
+ }
172
+ },
173
+ };
174
+ export const textTools = [
175
+ getTextWorkspaceTool,
176
+ readTextWorkspaceTool,
177
+ writeTextWorkspaceTool,
178
+ deleteTextWorkspaceTool,
179
+ getTextWorkspaceEditUrlTool,
180
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "AIquila - MCP server for Nextcloud integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",