aiquila-mcp 0.3.5 → 0.3.7
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/dist/client/text.js +51 -0
- package/dist/tool-registry.js +2 -0
- package/dist/tools/apps/calendar.js +154 -10
- package/dist/tools/apps/text.js +180 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/tool-registry.js
CHANGED
|
@@ -36,6 +36,7 @@ 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
38
|
import { formsTools } from './tools/apps/forms.js';
|
|
39
|
+
import { textTools } from './tools/apps/text.js';
|
|
39
40
|
/**
|
|
40
41
|
* Single source of truth for tool-to-Nextcloud-app mapping.
|
|
41
42
|
*
|
|
@@ -76,6 +77,7 @@ export const TOOL_REGISTRY = [
|
|
|
76
77
|
{ category: 'bookmarks', appIds: ['bookmarks'], tools: bookmarksTools },
|
|
77
78
|
{ category: 'polls', appIds: ['polls'], tools: pollsTools },
|
|
78
79
|
{ category: 'forms', appIds: ['forms'], tools: formsTools },
|
|
80
|
+
{ category: 'text', appIds: ['text'], tools: textTools },
|
|
79
81
|
{ category: 'assistant', appIds: ['assistant'], tools: assistantTools },
|
|
80
82
|
{ category: 'translate', appIds: ['text_translate', 'translate'], tools: translateTools },
|
|
81
83
|
{ category: 'user_status', appIds: ['user_status'], tools: userStatusTools },
|
|
@@ -52,17 +52,129 @@ function setICalProperty(icalData, propName, value) {
|
|
|
52
52
|
}
|
|
53
53
|
return icalData.replace(/END:VEVENT/, `${propName}:${value}\r\nEND:VEVENT`);
|
|
54
54
|
}
|
|
55
|
-
function setICalDateProperty(icalData, propName, value) {
|
|
55
|
+
function setICalDateProperty(icalData, propName, value, tzid) {
|
|
56
56
|
const regex = new RegExp(`^${propName}(;[^:]*)?:.*$`, 'm');
|
|
57
57
|
if (value === null) {
|
|
58
58
|
return icalData.replace(regex, '').replace(/(\r?\n){2,}/g, '\r\n');
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
let formatted;
|
|
61
|
+
if (value.length === 8) {
|
|
62
|
+
formatted = `${propName};VALUE=DATE:${value}`;
|
|
63
|
+
}
|
|
64
|
+
else if (tzid) {
|
|
65
|
+
formatted = `${propName};TZID=${tzid}:${normalizeLocalICal(value, tzid)}`;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
formatted = `${propName}:${value}`;
|
|
69
|
+
}
|
|
61
70
|
if (regex.test(icalData)) {
|
|
62
71
|
return icalData.replace(regex, formatted);
|
|
63
72
|
}
|
|
64
73
|
return icalData.replace(/END:VEVENT/, `${formatted}\r\nEND:VEVENT`);
|
|
65
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Format the UTC offset for a given IANA zone at a specific instant.
|
|
77
|
+
* Returns "+HHMM" or "-HHMM" (e.g. "+0200", "-0500", "+0000").
|
|
78
|
+
*/
|
|
79
|
+
function getTimezoneOffset(tzid, date) {
|
|
80
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
81
|
+
timeZone: tzid,
|
|
82
|
+
timeZoneName: 'longOffset',
|
|
83
|
+
});
|
|
84
|
+
const tzPart = fmt.formatToParts(date).find((p) => p.type === 'timeZoneName')?.value || 'GMT';
|
|
85
|
+
// tzPart is "GMT", "GMT+02:00", "GMT-05:30", or "GMT+5:45" depending on engine
|
|
86
|
+
const m = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
|
87
|
+
if (!m)
|
|
88
|
+
return '+0000';
|
|
89
|
+
const sign = m[1];
|
|
90
|
+
const hh = m[2].padStart(2, '0');
|
|
91
|
+
const mm = m[3] ?? '00';
|
|
92
|
+
return `${sign}${hh}${mm}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build a minimal but RFC 5545-conformant VTIMEZONE component for the given
|
|
96
|
+
* IANA zone. Uses two reference instants in the current year to detect DST.
|
|
97
|
+
* No external tzdata required — sufficient for iOS / Thunderbird to accept
|
|
98
|
+
* the event and apply the TZID correctly to upcoming occurrences.
|
|
99
|
+
*/
|
|
100
|
+
export function buildVTimezone(tzid) {
|
|
101
|
+
const year = new Date().getUTCFullYear();
|
|
102
|
+
const janOffset = getTimezoneOffset(tzid, new Date(Date.UTC(year, 0, 15)));
|
|
103
|
+
const julOffset = getTimezoneOffset(tzid, new Date(Date.UTC(year, 6, 15)));
|
|
104
|
+
const lines = ['BEGIN:VTIMEZONE', `TZID:${tzid}`];
|
|
105
|
+
if (janOffset === julOffset) {
|
|
106
|
+
lines.push('BEGIN:STANDARD', 'DTSTART:19700101T000000', `TZOFFSETFROM:${janOffset}`, `TZOFFSETTO:${janOffset}`, `TZNAME:${tzid}`, 'END:STANDARD');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Higher numeric offset = daylight, lower = standard (works for both hemispheres)
|
|
110
|
+
const standardOffset = janOffset < julOffset ? janOffset : julOffset;
|
|
111
|
+
const daylightOffset = janOffset < julOffset ? julOffset : janOffset;
|
|
112
|
+
lines.push('BEGIN:STANDARD', 'DTSTART:19701101T000000', `TZOFFSETFROM:${daylightOffset}`, `TZOFFSETTO:${standardOffset}`, `TZNAME:${tzid}`, 'END:STANDARD', 'BEGIN:DAYLIGHT', 'DTSTART:19700301T000000', `TZOFFSETFROM:${standardOffset}`, `TZOFFSETTO:${daylightOffset}`, `TZNAME:${tzid}`, 'END:DAYLIGHT');
|
|
113
|
+
}
|
|
114
|
+
lines.push('END:VTIMEZONE');
|
|
115
|
+
return lines.join('\r\n');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Convert a UTC iCal datetime ("YYYYMMDDTHHmmssZ") to wall-clock time in the
|
|
119
|
+
* given IANA zone, returning "YYYYMMDDTHHmmss" (no Z).
|
|
120
|
+
*/
|
|
121
|
+
function convertUtcICalToLocalICal(utcICal, tzid) {
|
|
122
|
+
const m = utcICal.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
|
|
123
|
+
if (!m)
|
|
124
|
+
return utcICal.replace(/Z$/, '');
|
|
125
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
126
|
+
const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
|
|
127
|
+
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
128
|
+
timeZone: tzid,
|
|
129
|
+
year: 'numeric',
|
|
130
|
+
month: '2-digit',
|
|
131
|
+
day: '2-digit',
|
|
132
|
+
hour: '2-digit',
|
|
133
|
+
minute: '2-digit',
|
|
134
|
+
second: '2-digit',
|
|
135
|
+
hour12: false,
|
|
136
|
+
});
|
|
137
|
+
const parts = fmt.formatToParts(instant);
|
|
138
|
+
const get = (t) => parts.find((p) => p.type === t)?.value ?? '00';
|
|
139
|
+
let hour = get('hour');
|
|
140
|
+
if (hour === '24')
|
|
141
|
+
hour = '00';
|
|
142
|
+
return `${get('year')}${get('month')}${get('day')}T${hour}${get('minute')}${get('second')}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* If `value` ends in Z, convert it to wall-clock time in `tzid`. Otherwise
|
|
146
|
+
* assume it is already a floating local datetime in that zone.
|
|
147
|
+
*/
|
|
148
|
+
function normalizeLocalICal(value, tzid) {
|
|
149
|
+
if (/Z$/.test(value))
|
|
150
|
+
return convertUtcICalToLocalICal(value, tzid);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Add `hours` to a floating "YYYYMMDDTHHmmss" string, returning the same form.
|
|
155
|
+
*/
|
|
156
|
+
function addHoursToLocalICal(local, hours) {
|
|
157
|
+
const m = local.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
|
|
158
|
+
if (!m)
|
|
159
|
+
return local;
|
|
160
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
161
|
+
const dt = new Date(Date.UTC(+y, +mo - 1, +d, +h + hours, +mi, +s));
|
|
162
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
163
|
+
return (`${dt.getUTCFullYear()}${pad(dt.getUTCMonth() + 1)}${pad(dt.getUTCDate())}` +
|
|
164
|
+
`T${pad(dt.getUTCHours())}${pad(dt.getUTCMinutes())}${pad(dt.getUTCSeconds())}`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Ensure the VCALENDAR contains a VTIMEZONE with the requested TZID. Insert
|
|
168
|
+
* one before the first VEVENT if not already present.
|
|
169
|
+
*/
|
|
170
|
+
function ensureVTimezone(icalData, tzid) {
|
|
171
|
+
const escaped = tzid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
172
|
+
const existing = new RegExp(`BEGIN:VTIMEZONE[\\s\\S]*?TZID:${escaped}[\\s\\S]*?END:VTIMEZONE`);
|
|
173
|
+
if (existing.test(icalData))
|
|
174
|
+
return icalData;
|
|
175
|
+
const vtz = buildVTimezone(tzid);
|
|
176
|
+
return icalData.replace(/BEGIN:VEVENT/, `${vtz}\r\nBEGIN:VEVENT`);
|
|
177
|
+
}
|
|
66
178
|
// ---------------------------------------------------------------------------
|
|
67
179
|
// Parsing
|
|
68
180
|
// ---------------------------------------------------------------------------
|
|
@@ -703,6 +815,10 @@ export const createEventTool = {
|
|
|
703
815
|
.number()
|
|
704
816
|
.optional()
|
|
705
817
|
.describe('Reminder in minutes before the event (e.g. 15 for 15 min before)'),
|
|
818
|
+
tzid: z
|
|
819
|
+
.string()
|
|
820
|
+
.optional()
|
|
821
|
+
.describe('IANA time zone (e.g. "Europe/Berlin"). When set, a VTIMEZONE block is emitted and dtstart/dtend use TZID instead of UTC. Required for reliable iOS Calendar and Thunderbird CalDAV sync of timed events. UTC inputs (trailing Z) are converted to wall-clock time in this zone.'),
|
|
706
822
|
}),
|
|
707
823
|
handler: async (args) => {
|
|
708
824
|
try {
|
|
@@ -714,6 +830,8 @@ export const createEventTool = {
|
|
|
714
830
|
await assertCalendarSupportsEvents(calendarBaseUrl, args.calendarName);
|
|
715
831
|
const now = icalNow();
|
|
716
832
|
const isAllDay = args.dtstart.length === 8;
|
|
833
|
+
const tzid = !isAllDay ? args.tzid : undefined;
|
|
834
|
+
const localDtstart = tzid ? normalizeLocalICal(args.dtstart, tzid) : args.dtstart;
|
|
717
835
|
// Calculate default end
|
|
718
836
|
let dtend = args.dtend;
|
|
719
837
|
if (!dtend) {
|
|
@@ -722,18 +840,34 @@ export const createEventTool = {
|
|
|
722
840
|
const d = new Date(parseInt(args.dtstart.slice(0, 4)), parseInt(args.dtstart.slice(4, 6)) - 1, parseInt(args.dtstart.slice(6, 8)) + 1);
|
|
723
841
|
dtend = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
724
842
|
}
|
|
843
|
+
else if (tzid) {
|
|
844
|
+
// Timed with TZID: 1 hour after local wall time
|
|
845
|
+
dtend = addHoursToLocalICal(localDtstart, 1);
|
|
846
|
+
}
|
|
725
847
|
else {
|
|
726
|
-
// Timed: 1 hour later
|
|
848
|
+
// Timed: 1 hour later (UTC)
|
|
727
849
|
const startDate = new Date(parseInt(args.dtstart.slice(0, 4)), parseInt(args.dtstart.slice(4, 6)) - 1, parseInt(args.dtstart.slice(6, 8)), parseInt(args.dtstart.slice(9, 11)), parseInt(args.dtstart.slice(11, 13)), parseInt(args.dtstart.slice(13, 15)));
|
|
728
850
|
startDate.setHours(startDate.getHours() + 1);
|
|
729
851
|
dtend = startDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
730
852
|
}
|
|
731
853
|
}
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
854
|
+
const localDtend = tzid ? normalizeLocalICal(dtend, tzid) : dtend;
|
|
855
|
+
let dtstartProp;
|
|
856
|
+
let dtendProp;
|
|
857
|
+
if (isAllDay) {
|
|
858
|
+
dtstartProp = `DTSTART;VALUE=DATE:${args.dtstart}`;
|
|
859
|
+
dtendProp = `DTEND;VALUE=DATE:${dtend}`;
|
|
860
|
+
}
|
|
861
|
+
else if (tzid) {
|
|
862
|
+
dtstartProp = `DTSTART;TZID=${tzid}:${localDtstart}`;
|
|
863
|
+
dtendProp = `DTEND;TZID=${tzid}:${localDtend}`;
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
dtstartProp = `DTSTART:${args.dtstart}`;
|
|
867
|
+
dtendProp = `DTEND:${dtend}`;
|
|
868
|
+
}
|
|
869
|
+
const vtimezoneBlock = tzid ? `${buildVTimezone(tzid)}\r\n` : '';
|
|
870
|
+
let vevent = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//AIquila//MCP Server//EN\r\n${vtimezoneBlock}BEGIN:VEVENT\r\nUID:${eventUid}\r\nDTSTAMP:${now}\r\nCREATED:${now}\r\nLAST-MODIFIED:${now}\r\n${dtstartProp}\r\n${dtendProp}\r\nSUMMARY:${escapeICalValue(args.summary)}`;
|
|
737
871
|
if (args.location) {
|
|
738
872
|
vevent += `\r\nLOCATION:${escapeICalValue(args.location)}`;
|
|
739
873
|
}
|
|
@@ -870,6 +1004,10 @@ export const updateEventTool = {
|
|
|
870
1004
|
.nullable()
|
|
871
1005
|
.optional()
|
|
872
1006
|
.describe('Replace attendees list, or null to remove all attendees'),
|
|
1007
|
+
tzid: z
|
|
1008
|
+
.string()
|
|
1009
|
+
.optional()
|
|
1010
|
+
.describe('IANA time zone (e.g. "Europe/Berlin") for the updated dtstart/dtend. When set, a VTIMEZONE block is added (if missing) and DTSTART/DTEND use TZID instead of UTC. Required for iOS/Thunderbird CalDAV sync.'),
|
|
873
1011
|
}),
|
|
874
1012
|
handler: async (args) => {
|
|
875
1013
|
try {
|
|
@@ -879,11 +1017,17 @@ export const updateEventTool = {
|
|
|
879
1017
|
if (args.summary !== undefined) {
|
|
880
1018
|
modified = setICalProperty(modified, 'SUMMARY', escapeICalValue(args.summary));
|
|
881
1019
|
}
|
|
1020
|
+
// Only timed datetimes carry a TZID; all-day (length 8) values are dates.
|
|
1021
|
+
const dtstartTz = args.tzid && args.dtstart && args.dtstart.length > 8 ? args.tzid : undefined;
|
|
1022
|
+
const dtendTz = args.tzid && args.dtend && args.dtend.length > 8 ? args.tzid : undefined;
|
|
882
1023
|
if (args.dtstart !== undefined) {
|
|
883
|
-
modified = setICalDateProperty(modified, 'DTSTART', args.dtstart);
|
|
1024
|
+
modified = setICalDateProperty(modified, 'DTSTART', args.dtstart, dtstartTz);
|
|
884
1025
|
}
|
|
885
1026
|
if (args.dtend !== undefined) {
|
|
886
|
-
modified = setICalDateProperty(modified, 'DTEND', args.dtend);
|
|
1027
|
+
modified = setICalDateProperty(modified, 'DTEND', args.dtend, dtendTz);
|
|
1028
|
+
}
|
|
1029
|
+
if (args.tzid && (dtstartTz || dtendTz)) {
|
|
1030
|
+
modified = ensureVTimezone(modified, args.tzid);
|
|
887
1031
|
}
|
|
888
1032
|
if (args.location !== undefined) {
|
|
889
1033
|
modified = setICalProperty(modified, 'LOCATION', args.location ? escapeICalValue(args.location) : null);
|
|
@@ -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
|
+
];
|