aiquila-mcp 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/client/social.js +68 -0
- package/dist/tool-registry.js +2 -0
- package/dist/tools/apps/social.js +835 -0
- package/package.json +2 -2
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, forms, and more.
|
|
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. 342 tools across 44 categories.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
@@ -75,6 +75,7 @@ the [AIquila Nextcloud app](https://github.com/elgorro/aiquila/blob/main/docs/in
|
|
|
75
75
|
| Category | Tools |
|
|
76
76
|
| ---------------- | ----: |
|
|
77
77
|
| Maps | 40 |
|
|
78
|
+
| Social | 26 |
|
|
78
79
|
| Forms | 25 |
|
|
79
80
|
| Polls | 21 |
|
|
80
81
|
| News | 17 |
|
|
@@ -98,9 +99,9 @@ the [AIquila Nextcloud app](https://github.com/elgorro/aiquila/blob/main/docs/in
|
|
|
98
99
|
| Translate | 1 |
|
|
99
100
|
| Social Sharing | 1 |
|
|
100
101
|
| Recommendations | 1 |
|
|
101
|
-
| **Subtotal** | **
|
|
102
|
+
| **Subtotal** | **248** |
|
|
102
103
|
|
|
103
|
-
**Total:
|
|
104
|
+
**Total: 342 tools.**
|
|
104
105
|
|
|
105
106
|
## Configuration
|
|
106
107
|
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
* Nextcloud Social — Mastodon client API.
|
|
7
|
+
*
|
|
8
|
+
* Pinned against Nextcloud Social v0.24.1. The app is pre-1.0 and its API is
|
|
9
|
+
* still moving; re-check the routes before bumping the pinned version.
|
|
10
|
+
*
|
|
11
|
+
* Social registers every API route as a `FrontpageRoute`, so the Mastodon
|
|
12
|
+
* paths are served under `/index.php/apps/social/` rather than at the domain
|
|
13
|
+
* root — which is why third-party Mastodon clients cannot reach it yet.
|
|
14
|
+
* Upstream intends to move them, so SOCIAL_API_BASE below is the single place
|
|
15
|
+
* that has to change when they do.
|
|
16
|
+
*
|
|
17
|
+
* Authentication is Basic auth with a Nextcloud app password, not an OAuth
|
|
18
|
+
* bearer token. Social resolves the caller from the bearer token *or* from the
|
|
19
|
+
* Nextcloud session when the request passes the CSRF check, and Nextcloud
|
|
20
|
+
* treats an `OCS-APIRequest` header as passing it — so the same headers every
|
|
21
|
+
* other AIquila client sends are enough. Social's own OAuth only supports the
|
|
22
|
+
* authorization-code grant, which needs a human at a consent page.
|
|
23
|
+
*/
|
|
24
|
+
const SOCIAL_API_BASE = '/index.php/apps/social';
|
|
25
|
+
/**
|
|
26
|
+
* Make an authenticated request to the Nextcloud Social Mastodon API.
|
|
27
|
+
*
|
|
28
|
+
* Base path: /index.php/apps/social
|
|
29
|
+
*
|
|
30
|
+
* Returns plain JSON (no OCS envelope), like the News and Notes APIs.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchSocialAPI(endpoint, options = {}) {
|
|
33
|
+
const config = getNextcloudConfig();
|
|
34
|
+
const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
|
|
35
|
+
let url = `${config.url}${SOCIAL_API_BASE}${endpoint}`;
|
|
36
|
+
if (options.queryParams) {
|
|
37
|
+
const params = new URLSearchParams();
|
|
38
|
+
for (const [key, value] of Object.entries(options.queryParams)) {
|
|
39
|
+
if (value !== undefined)
|
|
40
|
+
params.append(key, String(value));
|
|
41
|
+
}
|
|
42
|
+
const qs = params.toString();
|
|
43
|
+
if (qs)
|
|
44
|
+
url += `?${qs}`;
|
|
45
|
+
}
|
|
46
|
+
const headers = {
|
|
47
|
+
Authorization: `Basic ${auth}`,
|
|
48
|
+
'OCS-APIRequest': 'true',
|
|
49
|
+
Accept: 'application/json',
|
|
50
|
+
};
|
|
51
|
+
let body;
|
|
52
|
+
if (options.body !== undefined) {
|
|
53
|
+
body = JSON.stringify(options.body);
|
|
54
|
+
headers['Content-Type'] = 'application/json';
|
|
55
|
+
}
|
|
56
|
+
const method = options.method ?? 'GET';
|
|
57
|
+
const t0 = Date.now();
|
|
58
|
+
const response = await fetch(url, { method, headers, body });
|
|
59
|
+
logger.trace({ method, url, status: response.status, ms: Date.now() - t0 }, '[social] HTTP');
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
throw new ApiError(response.status, response.statusText, text);
|
|
63
|
+
}
|
|
64
|
+
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
65
|
+
return (await response.json());
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
package/dist/tool-registry.js
CHANGED
|
@@ -45,6 +45,7 @@ import { formsTools } from './tools/apps/forms.js';
|
|
|
45
45
|
import { textTools } from './tools/apps/text.js';
|
|
46
46
|
import { recommendationsTools } from './tools/apps/recommendations.js';
|
|
47
47
|
import { socialSharingTools } from './tools/apps/social-sharing.js';
|
|
48
|
+
import { socialTools } from './tools/apps/social.js';
|
|
48
49
|
import { passmanTools } from './tools/apps/passman.js';
|
|
49
50
|
/**
|
|
50
51
|
* Single source of truth for tool-to-Nextcloud-app mapping.
|
|
@@ -103,6 +104,7 @@ export const TOOL_REGISTRY = [
|
|
|
103
104
|
tools: termsOfServiceTools,
|
|
104
105
|
},
|
|
105
106
|
{ category: 'recommendations', appIds: ['recommendations'], tools: recommendationsTools },
|
|
107
|
+
{ category: 'social', appIds: ['social'], tools: socialTools },
|
|
106
108
|
{
|
|
107
109
|
category: 'social_sharing',
|
|
108
110
|
appIds: [
|
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { fetchSocialAPI, } from '../../client/social.js';
|
|
4
|
+
import { handleAppError } from '../error-utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Nextcloud Social App Tools
|
|
7
|
+
*
|
|
8
|
+
* Reads and writes the fediverse through the Mastodon client API that
|
|
9
|
+
* Nextcloud Social exposes. Pinned against Social v0.24.1 — the app is pre-1.0
|
|
10
|
+
* and its API is still moving.
|
|
11
|
+
*
|
|
12
|
+
* Out of scope in this module, deliberately: reports, blocks, mutes and flags;
|
|
13
|
+
* the Pixelfed and PeerTube routes; admin and moderation; filters; list,
|
|
14
|
+
* scheduled-status and migration management.
|
|
15
|
+
*/
|
|
16
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
17
|
+
/** Social clamps timeline reads to `ProbeOptions::MAX_LIMIT`; mirror it here. */
|
|
18
|
+
const TIMELINE_MAX = 50;
|
|
19
|
+
/** `/api/v2/search` clamps to 40. */
|
|
20
|
+
const SEARCH_MAX = 40;
|
|
21
|
+
const VISIBILITIES = ['public', 'unlisted', 'private', 'direct'];
|
|
22
|
+
/** What every write tool says, because none of it can be taken back. */
|
|
23
|
+
const FEDERATION_WARNING = 'This publishes to the fediverse: the action is delivered to other servers immediately and cannot be recalled. ' +
|
|
24
|
+
'A public post is world-readable.';
|
|
25
|
+
const STATUS_ERRORS = {
|
|
26
|
+
401: 'Not authenticated to Nextcloud Social. Check NEXTCLOUD_USER and NEXTCLOUD_PASSWORD, and that the Social app is enabled for that user.',
|
|
27
|
+
404: 'No such status — it may have been deleted, or it is not visible to you.',
|
|
28
|
+
};
|
|
29
|
+
const ACCOUNT_ERRORS = {
|
|
30
|
+
401: 'Not authenticated to Nextcloud Social. Check NEXTCLOUD_USER and NEXTCLOUD_PASSWORD, and that the Social app is enabled for that user.',
|
|
31
|
+
404: 'No such account. Use the full handle (user@server) for a remote account.',
|
|
32
|
+
};
|
|
33
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
34
|
+
function text(body) {
|
|
35
|
+
return { content: [{ type: 'text', text: body }] };
|
|
36
|
+
}
|
|
37
|
+
/** Strip HTML tags and collapse whitespace, then truncate. */
|
|
38
|
+
function stripHtml(html, max = 280) {
|
|
39
|
+
const plain = html
|
|
40
|
+
.replace(/<[^>]+>/g, ' ')
|
|
41
|
+
.replace(/ /g, ' ')
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
return plain.length > max ? `${plain.slice(0, max)}…` : plain;
|
|
45
|
+
}
|
|
46
|
+
const pageSchema = {
|
|
47
|
+
limit: z.number().optional().describe(`Statuses to return (default 20, max ${TIMELINE_MAX})`),
|
|
48
|
+
max_id: z.number().optional().describe('Only statuses older than this status id (page back)'),
|
|
49
|
+
min_id: z.number().optional().describe('Only statuses newer than this status id (page forward)'),
|
|
50
|
+
};
|
|
51
|
+
function pageParams(args) {
|
|
52
|
+
return {
|
|
53
|
+
limit: Math.min(args.limit ?? 20, TIMELINE_MAX),
|
|
54
|
+
max_id: args.max_id,
|
|
55
|
+
min_id: args.min_id,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function handle(a) {
|
|
59
|
+
if (!a)
|
|
60
|
+
return 'unknown';
|
|
61
|
+
return a.display_name ? `${a.display_name} (@${a.acct})` : `@${a.acct}`;
|
|
62
|
+
}
|
|
63
|
+
function formatStatus(s) {
|
|
64
|
+
const shown = s.reblog ?? s;
|
|
65
|
+
const lines = [
|
|
66
|
+
s.reblog
|
|
67
|
+
? `- **${handle(s.account)}** boosted **${handle(shown.account)}** (ID: ${shown.id})`
|
|
68
|
+
: `- **${handle(s.account)}** (ID: ${s.id})`,
|
|
69
|
+
];
|
|
70
|
+
lines.push(` ${shown.created_at} | ${shown.visibility}${shown.edited_at ? ' | edited' : ''}`);
|
|
71
|
+
if (shown.spoiler_text)
|
|
72
|
+
lines.push(` ⚠ CW: ${shown.spoiler_text}`);
|
|
73
|
+
lines.push(` ${stripHtml(shown.content) || '(no text)'}`);
|
|
74
|
+
if (shown.media_attachments?.length) {
|
|
75
|
+
const media = shown.media_attachments
|
|
76
|
+
.map((m) => `${m.type}${m.description ? ` — ${m.description}` : ''}`)
|
|
77
|
+
.join('; ');
|
|
78
|
+
lines.push(` 📎 ${shown.media_attachments.length} attachment(s): ${media}`);
|
|
79
|
+
}
|
|
80
|
+
if (shown.poll) {
|
|
81
|
+
const opts = shown.poll.options.map((o) => `${o.title} (${o.votes_count ?? 0})`).join(', ');
|
|
82
|
+
lines.push(` 📊 Poll${shown.poll.expired ? ' (closed)' : ''}: ${opts}`);
|
|
83
|
+
}
|
|
84
|
+
const flags = [
|
|
85
|
+
shown.favourited ? 'favourited' : null,
|
|
86
|
+
shown.reblogged ? 'boosted' : null,
|
|
87
|
+
shown.bookmarked ? 'bookmarked' : null,
|
|
88
|
+
].filter(Boolean);
|
|
89
|
+
lines.push(` ↩ ${shown.replies_count} | 🔁 ${shown.reblogs_count} | ⭐ ${shown.favourites_count}` +
|
|
90
|
+
(flags.length ? ` | ${flags.join(', ')}` : ''));
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
function formatAccount(a) {
|
|
94
|
+
const lines = [`- **${handle(a)}** (ID: ${a.id})${a.locked ? ' 🔒' : ''}${a.bot ? ' 🤖' : ''}`];
|
|
95
|
+
lines.push(` ${a.followers_count} followers | ${a.following_count} following | ${a.statuses_count} posts`);
|
|
96
|
+
if (a.note)
|
|
97
|
+
lines.push(` ${stripHtml(a.note, 200)}`);
|
|
98
|
+
lines.push(` ${a.url}`);
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
function formatStatuses(statuses, heading, empty) {
|
|
102
|
+
if (!statuses || statuses.length === 0)
|
|
103
|
+
return text(empty);
|
|
104
|
+
return text(`${heading} (${statuses.length}):\n\n${statuses.map(formatStatus).join('\n\n')}`);
|
|
105
|
+
}
|
|
106
|
+
/** Read one timeline, with the same pagination and rendering everywhere. */
|
|
107
|
+
async function timeline(endpoint, args, extra, heading, empty) {
|
|
108
|
+
const statuses = await fetchSocialAPI(endpoint, {
|
|
109
|
+
queryParams: { ...pageParams(args), ...extra },
|
|
110
|
+
});
|
|
111
|
+
return formatStatuses(statuses, heading, empty);
|
|
112
|
+
}
|
|
113
|
+
const READ_ONLY = {
|
|
114
|
+
readOnlyHint: true,
|
|
115
|
+
destructiveHint: false,
|
|
116
|
+
idempotentHint: true,
|
|
117
|
+
openWorldHint: true,
|
|
118
|
+
};
|
|
119
|
+
// ── Timelines ───────────────────────────────────────────────────────────────
|
|
120
|
+
export const homeTimelineTool = {
|
|
121
|
+
name: 'social_home_timeline',
|
|
122
|
+
title: 'Read Social Home Timeline',
|
|
123
|
+
annotations: READ_ONLY,
|
|
124
|
+
description: 'Read the home timeline in Nextcloud Social: posts from the accounts this user follows, newest first.',
|
|
125
|
+
inputSchema: z.object({ ...pageSchema }),
|
|
126
|
+
handler: async (args = {}) => {
|
|
127
|
+
try {
|
|
128
|
+
// the trailing slash is part of the registered route
|
|
129
|
+
return await timeline('/api/v1/timelines/home/', args, {}, 'Home timeline', 'Home timeline is empty. Follow some accounts to see posts here.');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return handleAppError(error, 'Error reading the home timeline', ACCOUNT_ERRORS);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
export const publicTimelineTool = {
|
|
137
|
+
name: 'social_public_timeline',
|
|
138
|
+
title: 'Read Social Public Timeline',
|
|
139
|
+
annotations: READ_ONLY,
|
|
140
|
+
description: 'Read the public timeline in Nextcloud Social — every public post this server knows about. Set local to true for posts from this server only.',
|
|
141
|
+
inputSchema: z.object({
|
|
142
|
+
...pageSchema,
|
|
143
|
+
local: z.boolean().optional().describe('Only posts from this Nextcloud server (default false)'),
|
|
144
|
+
}),
|
|
145
|
+
handler: async (args = {}) => {
|
|
146
|
+
try {
|
|
147
|
+
return await timeline('/api/v1/timelines/public/', args, { local: args.local ?? false }, args.local ? 'Local timeline' : 'Public timeline', 'The public timeline is empty.');
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return handleAppError(error, 'Error reading the public timeline', ACCOUNT_ERRORS);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
export const hashtagTimelineTool = {
|
|
155
|
+
name: 'social_hashtag_timeline',
|
|
156
|
+
title: 'Read Social Hashtag Timeline',
|
|
157
|
+
annotations: READ_ONLY,
|
|
158
|
+
description: 'Read the public posts carrying a given hashtag in Nextcloud Social.',
|
|
159
|
+
inputSchema: z.object({
|
|
160
|
+
...pageSchema,
|
|
161
|
+
hashtag: z.string().describe('The hashtag, without the leading #'),
|
|
162
|
+
local: z.boolean().optional().describe('Only posts from this Nextcloud server (default false)'),
|
|
163
|
+
}),
|
|
164
|
+
handler: async (args) => {
|
|
165
|
+
try {
|
|
166
|
+
const tag = args.hashtag.replace(/^#/, '');
|
|
167
|
+
return await timeline(`/api/v1/timelines/tag/${encodeURIComponent(tag)}`, args, { local: args.local ?? false }, `#${tag}`, `No posts tagged #${tag}.`);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return handleAppError(error, 'Error reading the hashtag timeline', ACCOUNT_ERRORS);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
export const listTimelineTool = {
|
|
175
|
+
name: 'social_list_timeline',
|
|
176
|
+
title: 'Read Social List Timeline',
|
|
177
|
+
annotations: READ_ONLY,
|
|
178
|
+
description: 'Read the timeline of one of this user’s Social lists. Call it without list_id first to see the available lists and their ids.',
|
|
179
|
+
inputSchema: z.object({
|
|
180
|
+
...pageSchema,
|
|
181
|
+
list_id: z
|
|
182
|
+
.number()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe('The list to read; omit to list the available lists instead'),
|
|
185
|
+
}),
|
|
186
|
+
handler: async (args = {}) => {
|
|
187
|
+
try {
|
|
188
|
+
if (args.list_id === undefined) {
|
|
189
|
+
const lists = await fetchSocialAPI('/api/v1/lists');
|
|
190
|
+
if (!lists || lists.length === 0) {
|
|
191
|
+
return text('No lists. Create one in the Social app first.');
|
|
192
|
+
}
|
|
193
|
+
const formatted = lists.map((l) => `- **${l.title}** (ID: ${l.id})`).join('\n');
|
|
194
|
+
return text(`Lists (${lists.length}):\n\n${formatted}`);
|
|
195
|
+
}
|
|
196
|
+
return await timeline(`/api/v1/timelines/list/${args.list_id}`, args, {}, `List ${args.list_id}`, 'That list has no posts yet.');
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return handleAppError(error, 'Error reading the list timeline', {
|
|
200
|
+
...ACCOUNT_ERRORS,
|
|
201
|
+
404: 'No such list, or it does not belong to this user.',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
export const listSavedStatusesTool = {
|
|
207
|
+
name: 'social_list_saved_statuses',
|
|
208
|
+
title: 'List Saved Social Statuses',
|
|
209
|
+
annotations: READ_ONLY,
|
|
210
|
+
description: 'List the posts this user has favourited or bookmarked in Nextcloud Social.',
|
|
211
|
+
inputSchema: z.object({
|
|
212
|
+
...pageSchema,
|
|
213
|
+
kind: z
|
|
214
|
+
.enum(['favourites', 'bookmarks'])
|
|
215
|
+
.describe('Which collection to read: favourites (starred) or bookmarks (saved)'),
|
|
216
|
+
}),
|
|
217
|
+
handler: async (args) => {
|
|
218
|
+
try {
|
|
219
|
+
// `/favourites/` keeps its trailing slash; `/bookmarks` has none
|
|
220
|
+
const endpoint = args.kind === 'favourites' ? '/api/v1/favourites/' : '/api/v1/bookmarks';
|
|
221
|
+
return await timeline(endpoint, args, {}, args.kind === 'favourites' ? 'Favourites' : 'Bookmarks', `No ${args.kind} yet.`);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return handleAppError(error, `Error listing ${args.kind}`, ACCOUNT_ERRORS);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
// ── Statuses ────────────────────────────────────────────────────────────────
|
|
229
|
+
export const getStatusTool = {
|
|
230
|
+
name: 'social_get_status',
|
|
231
|
+
title: 'Get Social Status',
|
|
232
|
+
annotations: READ_ONLY,
|
|
233
|
+
description: 'Read one post in Nextcloud Social by its id, optionally with the thread around it (the posts it replies to and the replies to it).',
|
|
234
|
+
inputSchema: z.object({
|
|
235
|
+
status_id: z.number().describe('The numeric status id'),
|
|
236
|
+
include_context: z
|
|
237
|
+
.boolean()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe('Also fetch the surrounding thread (default false)'),
|
|
240
|
+
}),
|
|
241
|
+
handler: async (args) => {
|
|
242
|
+
try {
|
|
243
|
+
const status = await fetchSocialAPI(`/api/v1/statuses/${args.status_id}`);
|
|
244
|
+
const sections = [formatStatus(status)];
|
|
245
|
+
if (args.include_context) {
|
|
246
|
+
const context = await fetchSocialAPI(`/api/v1/statuses/${args.status_id}/context`);
|
|
247
|
+
const ancestors = context.ancestors ?? [];
|
|
248
|
+
const descendants = context.descendants ?? [];
|
|
249
|
+
if (ancestors.length) {
|
|
250
|
+
sections.unshift(`In reply to (${ancestors.length}):\n\n${ancestors.map(formatStatus).join('\n\n')}`);
|
|
251
|
+
}
|
|
252
|
+
if (descendants.length) {
|
|
253
|
+
sections.push(`Replies (${descendants.length}):\n\n${descendants.map(formatStatus).join('\n\n')}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return text(sections.join('\n\n---\n\n'));
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return handleAppError(error, 'Error reading the status', STATUS_ERRORS);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
export const statusHistoryTool = {
|
|
264
|
+
name: 'social_status_history',
|
|
265
|
+
title: 'Get Social Status Edit History',
|
|
266
|
+
annotations: READ_ONLY,
|
|
267
|
+
description: 'List the successive versions of a post that has been edited in Nextcloud Social.',
|
|
268
|
+
inputSchema: z.object({
|
|
269
|
+
status_id: z.number().describe('The numeric status id'),
|
|
270
|
+
}),
|
|
271
|
+
handler: async (args) => {
|
|
272
|
+
try {
|
|
273
|
+
const history = await fetchSocialAPI(`/api/v1/statuses/${args.status_id}/history`);
|
|
274
|
+
if (!history || history.length === 0) {
|
|
275
|
+
return text('This post has never been edited.');
|
|
276
|
+
}
|
|
277
|
+
const formatted = history
|
|
278
|
+
.map((h, i) => {
|
|
279
|
+
const lines = [`- **Version ${i + 1}** — ${h.created_at}`];
|
|
280
|
+
if (h.spoiler_text)
|
|
281
|
+
lines.push(` ⚠ CW: ${h.spoiler_text}`);
|
|
282
|
+
lines.push(` ${stripHtml(h.content) || '(no text)'}`);
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
})
|
|
285
|
+
.join('\n');
|
|
286
|
+
return text(`Edit history (${history.length}):\n\n${formatted}`);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return handleAppError(error, 'Error reading the edit history', STATUS_ERRORS);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
export const uploadMediaTool = {
|
|
294
|
+
name: 'social_upload_media',
|
|
295
|
+
title: 'Attach Nextcloud File to Social Post',
|
|
296
|
+
annotations: {
|
|
297
|
+
readOnlyHint: false,
|
|
298
|
+
destructiveHint: false,
|
|
299
|
+
idempotentHint: false,
|
|
300
|
+
openWorldHint: false,
|
|
301
|
+
},
|
|
302
|
+
description: 'Stage a file from this user’s Nextcloud storage as a media attachment for Nextcloud Social, and return the media id to pass to social_post_status. The file is copied at upload time, so moving or deleting the original later does not empty the post. Nothing is published until the post is made.',
|
|
303
|
+
inputSchema: z.object({
|
|
304
|
+
path: z
|
|
305
|
+
.string()
|
|
306
|
+
.describe('Path to the file inside the user’s Nextcloud files, e.g. Photos/sunset.jpg'),
|
|
307
|
+
description: z.string().optional().describe('Alt text describing the media, for accessibility'),
|
|
308
|
+
}),
|
|
309
|
+
handler: async (args) => {
|
|
310
|
+
try {
|
|
311
|
+
const media = await fetchSocialAPI('/api/v1/media/from-file', {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
body: { path: args.path, description: args.description ?? '' },
|
|
314
|
+
});
|
|
315
|
+
return text(`Attachment ready (media ID: ${media.id}, type: ${media.type}).\n` +
|
|
316
|
+
`Pass it to social_post_status as media_ids: ["${media.id}"].`);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
return handleAppError(error, 'Error attaching the file', {
|
|
320
|
+
...ACCOUNT_ERRORS,
|
|
321
|
+
404: `No such file: ${args.path}. The path is resolved inside this user’s own Nextcloud files.`,
|
|
322
|
+
413: 'The file is too large for Nextcloud Social to accept as an attachment.',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
export const postStatusTool = {
|
|
328
|
+
name: 'social_post_status',
|
|
329
|
+
title: 'Post to the Fediverse',
|
|
330
|
+
annotations: {
|
|
331
|
+
readOnlyHint: false,
|
|
332
|
+
destructiveHint: false,
|
|
333
|
+
idempotentHint: false,
|
|
334
|
+
openWorldHint: true,
|
|
335
|
+
},
|
|
336
|
+
description: `Publish a post from this user's Nextcloud Social account. ${FEDERATION_WARNING} ` +
|
|
337
|
+
'Supports replies, media attachments (see social_upload_media), polls, content warnings and visibility. ' +
|
|
338
|
+
'Confirm the wording and the visibility with the user before calling this.',
|
|
339
|
+
inputSchema: z.object({
|
|
340
|
+
status: z.string().describe('The text of the post'),
|
|
341
|
+
visibility: z
|
|
342
|
+
.enum(VISIBILITIES)
|
|
343
|
+
.optional()
|
|
344
|
+
.describe('Who sees it: public (default, world-readable and listed), unlisted, private (followers only), direct (mentioned accounts only). An unrecognised value is treated as direct'),
|
|
345
|
+
in_reply_to_id: z.number().optional().describe('Numeric id of the status this replies to'),
|
|
346
|
+
media_ids: z.array(z.string()).optional().describe('Media ids returned by social_upload_media'),
|
|
347
|
+
poll: z
|
|
348
|
+
.object({
|
|
349
|
+
options: z.array(z.string()).describe('The poll choices, 2 or more'),
|
|
350
|
+
expires_in: z.number().describe('Seconds until the poll closes, e.g. 86400 for a day'),
|
|
351
|
+
multiple: z.boolean().optional().describe('Allow more than one choice (default false)'),
|
|
352
|
+
})
|
|
353
|
+
.optional()
|
|
354
|
+
.describe('Attach a poll to the post'),
|
|
355
|
+
spoiler_text: z
|
|
356
|
+
.string()
|
|
357
|
+
.optional()
|
|
358
|
+
.describe('Content warning shown in place of the post until the reader expands it'),
|
|
359
|
+
sensitive: z.boolean().optional().describe('Mark attached media as sensitive'),
|
|
360
|
+
language: z.string().optional().describe('ISO 639 language code of the post, e.g. en'),
|
|
361
|
+
}),
|
|
362
|
+
handler: async (args) => {
|
|
363
|
+
try {
|
|
364
|
+
const body = {
|
|
365
|
+
status: args.status,
|
|
366
|
+
visibility: args.visibility ?? 'public',
|
|
367
|
+
};
|
|
368
|
+
if (args.in_reply_to_id !== undefined)
|
|
369
|
+
body.in_reply_to_id = args.in_reply_to_id;
|
|
370
|
+
if (args.media_ids?.length)
|
|
371
|
+
body.media_ids = args.media_ids;
|
|
372
|
+
if (args.poll)
|
|
373
|
+
body.poll = args.poll;
|
|
374
|
+
if (args.spoiler_text !== undefined)
|
|
375
|
+
body.spoiler_text = args.spoiler_text;
|
|
376
|
+
if (args.sensitive !== undefined)
|
|
377
|
+
body.sensitive = args.sensitive;
|
|
378
|
+
if (args.language !== undefined)
|
|
379
|
+
body.language = args.language;
|
|
380
|
+
const status = await fetchSocialAPI('/api/v1/statuses', {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
body,
|
|
383
|
+
});
|
|
384
|
+
return text(`Posted (ID: ${status.id}, visibility: ${status.visibility}).\n${status.url ?? ''}\n\n` +
|
|
385
|
+
formatStatus(status));
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return handleAppError(error, 'Error posting', {
|
|
389
|
+
...ACCOUNT_ERRORS,
|
|
390
|
+
404: 'The status being replied to does not exist.',
|
|
391
|
+
429: 'Rate limited by Nextcloud Social (30 posts per minute). Wait and try again.',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
export const deleteStatusTool = {
|
|
397
|
+
name: 'social_delete_status',
|
|
398
|
+
title: 'Delete Social Post',
|
|
399
|
+
annotations: {
|
|
400
|
+
readOnlyHint: false,
|
|
401
|
+
destructiveHint: true,
|
|
402
|
+
idempotentHint: true,
|
|
403
|
+
openWorldHint: true,
|
|
404
|
+
},
|
|
405
|
+
description: 'Delete one of this user’s own posts in Nextcloud Social. A delete is federated to the servers that received the post, but copies already shown or cached elsewhere may remain.',
|
|
406
|
+
inputSchema: z.object({
|
|
407
|
+
status_id: z.number().describe('The numeric status id'),
|
|
408
|
+
}),
|
|
409
|
+
handler: async (args) => {
|
|
410
|
+
try {
|
|
411
|
+
await fetchSocialAPI(`/api/v1/statuses/${args.status_id}`, { method: 'DELETE' });
|
|
412
|
+
return text(`Deleted status ${args.status_id}.`);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return handleAppError(error, 'Error deleting the status', {
|
|
416
|
+
...STATUS_ERRORS,
|
|
417
|
+
403: 'Only the author can delete a post.',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
// ── Interactions ────────────────────────────────────────────────────────────
|
|
423
|
+
/** The six interaction tools are one route with a different `act`. */
|
|
424
|
+
function interactionTool(config) {
|
|
425
|
+
return {
|
|
426
|
+
name: config.name,
|
|
427
|
+
title: config.title,
|
|
428
|
+
annotations: {
|
|
429
|
+
readOnlyHint: false,
|
|
430
|
+
destructiveHint: config.destructive,
|
|
431
|
+
idempotentHint: true,
|
|
432
|
+
openWorldHint: true,
|
|
433
|
+
},
|
|
434
|
+
description: config.description,
|
|
435
|
+
inputSchema: z.object({
|
|
436
|
+
status_id: z.number().describe('The numeric status id'),
|
|
437
|
+
}),
|
|
438
|
+
handler: async (args) => {
|
|
439
|
+
try {
|
|
440
|
+
await fetchSocialAPI(`/api/v1/statuses/${args.status_id}/${config.act}`, {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
});
|
|
443
|
+
return text(`${config.past} status ${args.status_id}.`);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
return handleAppError(error, `Error on ${config.act}`, STATUS_ERRORS);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
export const favouriteTool = interactionTool({
|
|
452
|
+
name: 'social_favourite',
|
|
453
|
+
title: 'Favourite Social Post',
|
|
454
|
+
act: 'favourite',
|
|
455
|
+
destructive: false,
|
|
456
|
+
past: 'Favourited',
|
|
457
|
+
description: `Favourite (star) a post in Nextcloud Social. ${FEDERATION_WARNING} The author's server is told who favourited it.`,
|
|
458
|
+
});
|
|
459
|
+
export const unfavouriteTool = interactionTool({
|
|
460
|
+
name: 'social_unfavourite',
|
|
461
|
+
title: 'Unfavourite Social Post',
|
|
462
|
+
act: 'unfavourite',
|
|
463
|
+
destructive: true,
|
|
464
|
+
past: 'Unfavourited',
|
|
465
|
+
description: 'Remove this user’s favourite from a post in Nextcloud Social.',
|
|
466
|
+
});
|
|
467
|
+
export const boostTool = interactionTool({
|
|
468
|
+
name: 'social_boost',
|
|
469
|
+
title: 'Boost Social Post',
|
|
470
|
+
act: 'reblog',
|
|
471
|
+
destructive: false,
|
|
472
|
+
past: 'Boosted',
|
|
473
|
+
description: `Boost (reblog) a post in Nextcloud Social, republishing it to this user's followers. ${FEDERATION_WARNING} Confirm with the user before boosting.`,
|
|
474
|
+
});
|
|
475
|
+
export const unboostTool = interactionTool({
|
|
476
|
+
name: 'social_unboost',
|
|
477
|
+
title: 'Undo Social Boost',
|
|
478
|
+
act: 'unreblog',
|
|
479
|
+
destructive: true,
|
|
480
|
+
past: 'Removed the boost of',
|
|
481
|
+
description: 'Undo a boost of a post in Nextcloud Social.',
|
|
482
|
+
});
|
|
483
|
+
export const bookmarkTool = interactionTool({
|
|
484
|
+
name: 'social_bookmark',
|
|
485
|
+
title: 'Bookmark Social Post',
|
|
486
|
+
act: 'bookmark',
|
|
487
|
+
destructive: false,
|
|
488
|
+
past: 'Bookmarked',
|
|
489
|
+
description: 'Bookmark a post in Nextcloud Social so it can be found again. Bookmarks are private to this user and are not federated.',
|
|
490
|
+
});
|
|
491
|
+
export const unbookmarkTool = interactionTool({
|
|
492
|
+
name: 'social_unbookmark',
|
|
493
|
+
title: 'Remove Social Bookmark',
|
|
494
|
+
act: 'unbookmark',
|
|
495
|
+
destructive: true,
|
|
496
|
+
past: 'Removed the bookmark from',
|
|
497
|
+
description: 'Remove a bookmark from a post in Nextcloud Social.',
|
|
498
|
+
});
|
|
499
|
+
// ── Accounts and follows ────────────────────────────────────────────────────
|
|
500
|
+
export const lookupAccountTool = {
|
|
501
|
+
name: 'social_lookup_account',
|
|
502
|
+
title: 'Look Up Social Account',
|
|
503
|
+
annotations: READ_ONLY,
|
|
504
|
+
description: 'Look up one fediverse account by handle in Nextcloud Social and return its profile and counts.',
|
|
505
|
+
inputSchema: z.object({
|
|
506
|
+
acct: z.string().describe('The handle: user for a local account, user@server for a remote one'),
|
|
507
|
+
}),
|
|
508
|
+
handler: async (args) => {
|
|
509
|
+
try {
|
|
510
|
+
const account = await fetchSocialAPI('/api/v1/accounts/lookup', {
|
|
511
|
+
queryParams: { acct: args.acct.replace(/^@/, '') },
|
|
512
|
+
});
|
|
513
|
+
return text(formatAccount(account));
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
return handleAppError(error, 'Error looking up the account', ACCOUNT_ERRORS);
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
export const accountStatusesTool = {
|
|
521
|
+
name: 'social_account_statuses',
|
|
522
|
+
title: 'Read Social Account Posts',
|
|
523
|
+
annotations: READ_ONLY,
|
|
524
|
+
description: 'Read the posts of one fediverse account in Nextcloud Social, newest first.',
|
|
525
|
+
inputSchema: z.object({
|
|
526
|
+
...pageSchema,
|
|
527
|
+
account: z.string().describe('Account id, or the handle (user@server)'),
|
|
528
|
+
only_media: z.boolean().optional().describe('Only posts carrying media (default false)'),
|
|
529
|
+
}),
|
|
530
|
+
handler: async (args) => {
|
|
531
|
+
try {
|
|
532
|
+
return await timeline(`/api/v1/accounts/${encodeURIComponent(args.account.replace(/^@/, ''))}/statuses`, args, { only_media: args.only_media ?? false }, `Posts by ${args.account}`, `No posts by ${args.account}.`);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
return handleAppError(error, 'Error reading the account’s posts', ACCOUNT_ERRORS);
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
export const listAccountFollowsTool = {
|
|
540
|
+
name: 'social_list_account_follows',
|
|
541
|
+
title: 'List Social Followers or Following',
|
|
542
|
+
annotations: READ_ONLY,
|
|
543
|
+
description: 'List the followers of a fediverse account, or the accounts it follows.',
|
|
544
|
+
inputSchema: z.object({
|
|
545
|
+
...pageSchema,
|
|
546
|
+
account: z.string().describe('Account id, or the handle (user@server)'),
|
|
547
|
+
direction: z
|
|
548
|
+
.enum(['followers', 'following'])
|
|
549
|
+
.describe('followers = who follows this account; following = who this account follows'),
|
|
550
|
+
}),
|
|
551
|
+
handler: async (args) => {
|
|
552
|
+
try {
|
|
553
|
+
const accounts = await fetchSocialAPI(`/api/v1/accounts/${encodeURIComponent(args.account.replace(/^@/, ''))}/${args.direction}`, { queryParams: pageParams(args) });
|
|
554
|
+
if (!accounts || accounts.length === 0) {
|
|
555
|
+
return text(`No ${args.direction} found for ${args.account}.`);
|
|
556
|
+
}
|
|
557
|
+
return text(`${args.direction === 'followers' ? 'Followers of' : 'Followed by'} ${args.account} ` +
|
|
558
|
+
`(${accounts.length}):\n\n${accounts.map(formatAccount).join('\n\n')}`);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
return handleAppError(error, `Error listing ${args.direction}`, ACCOUNT_ERRORS);
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
export const followAccountTool = {
|
|
566
|
+
name: 'social_follow_account',
|
|
567
|
+
title: 'Follow Fediverse Account',
|
|
568
|
+
annotations: {
|
|
569
|
+
readOnlyHint: false,
|
|
570
|
+
destructiveHint: false,
|
|
571
|
+
idempotentHint: true,
|
|
572
|
+
openWorldHint: true,
|
|
573
|
+
},
|
|
574
|
+
description: `Follow a fediverse account from this user's Nextcloud Social account. ${FEDERATION_WARNING} ` +
|
|
575
|
+
'The other server is told who followed; a locked account has to approve the request first.',
|
|
576
|
+
inputSchema: z.object({
|
|
577
|
+
account: z.string().describe('Account id, or the handle (user@server)'),
|
|
578
|
+
}),
|
|
579
|
+
handler: async (args) => {
|
|
580
|
+
try {
|
|
581
|
+
await fetchSocialAPI(`/api/v1/accounts/${encodeURIComponent(args.account.replace(/^@/, ''))}/follow`, { method: 'POST' });
|
|
582
|
+
return text(`Now following ${args.account} (a locked account has to approve first).`);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
return handleAppError(error, 'Error following the account', ACCOUNT_ERRORS);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
export const unfollowAccountTool = {
|
|
590
|
+
name: 'social_unfollow_account',
|
|
591
|
+
title: 'Unfollow Fediverse Account',
|
|
592
|
+
annotations: {
|
|
593
|
+
readOnlyHint: false,
|
|
594
|
+
destructiveHint: true,
|
|
595
|
+
idempotentHint: true,
|
|
596
|
+
openWorldHint: true,
|
|
597
|
+
},
|
|
598
|
+
description: 'Stop following a fediverse account from this user’s Nextcloud Social account.',
|
|
599
|
+
inputSchema: z.object({
|
|
600
|
+
account: z.string().describe('Account id, or the handle (user@server)'),
|
|
601
|
+
}),
|
|
602
|
+
handler: async (args) => {
|
|
603
|
+
try {
|
|
604
|
+
await fetchSocialAPI(`/api/v1/accounts/${encodeURIComponent(args.account.replace(/^@/, ''))}/unfollow`, { method: 'POST' });
|
|
605
|
+
return text(`No longer following ${args.account}.`);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
return handleAppError(error, 'Error unfollowing the account', ACCOUNT_ERRORS);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
export const listFollowRequestsTool = {
|
|
613
|
+
name: 'social_list_follow_requests',
|
|
614
|
+
title: 'List Social Follow Requests',
|
|
615
|
+
annotations: READ_ONLY,
|
|
616
|
+
description: 'List the accounts waiting for this user to approve their follow request in Nextcloud Social.',
|
|
617
|
+
inputSchema: z.object({}),
|
|
618
|
+
handler: async () => {
|
|
619
|
+
try {
|
|
620
|
+
const accounts = await fetchSocialAPI('/api/v1/follow_requests');
|
|
621
|
+
if (!accounts || accounts.length === 0) {
|
|
622
|
+
return text('No pending follow requests.');
|
|
623
|
+
}
|
|
624
|
+
return text(`Pending follow requests (${accounts.length}):\n\n${accounts.map(formatAccount).join('\n\n')}`);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
return handleAppError(error, 'Error listing follow requests', ACCOUNT_ERRORS);
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
export const respondFollowRequestTool = {
|
|
632
|
+
name: 'social_respond_follow_request',
|
|
633
|
+
title: 'Approve or Reject Social Follow Request',
|
|
634
|
+
annotations: {
|
|
635
|
+
readOnlyHint: false,
|
|
636
|
+
destructiveHint: false,
|
|
637
|
+
idempotentHint: true,
|
|
638
|
+
openWorldHint: true,
|
|
639
|
+
},
|
|
640
|
+
description: `Approve or reject a pending follow request in Nextcloud Social. ${FEDERATION_WARNING} ` +
|
|
641
|
+
'Approving lets that account see this user’s followers-only posts.',
|
|
642
|
+
inputSchema: z.object({
|
|
643
|
+
account_id: z
|
|
644
|
+
.string()
|
|
645
|
+
.describe('The id of the requesting account, from social_list_follow_requests'),
|
|
646
|
+
action: z.enum(['authorize', 'reject']).describe('authorize to approve, reject to decline'),
|
|
647
|
+
}),
|
|
648
|
+
handler: async (args) => {
|
|
649
|
+
try {
|
|
650
|
+
await fetchSocialAPI(`/api/v1/follow_requests/${encodeURIComponent(args.account_id)}/${args.action}`, { method: 'POST' });
|
|
651
|
+
return text(args.action === 'authorize'
|
|
652
|
+
? `Approved the follow request from account ${args.account_id}.`
|
|
653
|
+
: `Rejected the follow request from account ${args.account_id}.`);
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
return handleAppError(error, 'Error answering the follow request', {
|
|
657
|
+
...ACCOUNT_ERRORS,
|
|
658
|
+
404: 'No pending follow request from that account.',
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
// ── Notifications ───────────────────────────────────────────────────────────
|
|
664
|
+
export const listNotificationsTool = {
|
|
665
|
+
name: 'social_list_notifications',
|
|
666
|
+
title: 'List Social Notifications',
|
|
667
|
+
annotations: READ_ONLY,
|
|
668
|
+
description: 'List this user’s Nextcloud Social notifications — mentions, follows, favourites, boosts and poll results — grouped so that forty favourites of one post read as one line.',
|
|
669
|
+
inputSchema: z.object({
|
|
670
|
+
limit: z
|
|
671
|
+
.number()
|
|
672
|
+
.optional()
|
|
673
|
+
.describe(`Notifications to read before grouping (default 20, max ${TIMELINE_MAX})`),
|
|
674
|
+
max_id: z.number().optional().describe('Only notifications older than this id (page back)'),
|
|
675
|
+
types: z
|
|
676
|
+
.array(z.string())
|
|
677
|
+
.optional()
|
|
678
|
+
.describe('Only these types, e.g. ["mention","follow","favourite","reblog"]'),
|
|
679
|
+
exclude_types: z.array(z.string()).optional().describe('Skip these types'),
|
|
680
|
+
}),
|
|
681
|
+
handler: async (args = {}) => {
|
|
682
|
+
try {
|
|
683
|
+
const queryParams = {
|
|
684
|
+
limit: Math.min(args.limit ?? 20, TIMELINE_MAX),
|
|
685
|
+
max_id: args.max_id,
|
|
686
|
+
};
|
|
687
|
+
// the controller takes these as repeated array params
|
|
688
|
+
args.types?.forEach((t, i) => (queryParams[`types[${i}]`] = t));
|
|
689
|
+
args.exclude_types?.forEach((t, i) => (queryParams[`exclude_types[${i}]`] = t));
|
|
690
|
+
const page = await fetchSocialAPI('/api/v2/notifications', {
|
|
691
|
+
queryParams,
|
|
692
|
+
});
|
|
693
|
+
const groups = page.notification_groups ?? [];
|
|
694
|
+
if (groups.length === 0) {
|
|
695
|
+
return text('No notifications.');
|
|
696
|
+
}
|
|
697
|
+
const accounts = new Map((page.accounts ?? []).map((a) => [a.id, a]));
|
|
698
|
+
const statuses = new Map((page.statuses ?? []).map((s) => [s.id, s]));
|
|
699
|
+
const formatted = groups
|
|
700
|
+
.map((g) => {
|
|
701
|
+
const who = g.sample_account_ids.map((id) => handle(accounts.get(id))).join(', ');
|
|
702
|
+
const extra = g.notifications_count > g.sample_account_ids.length
|
|
703
|
+
? ` and ${g.notifications_count - g.sample_account_ids.length} more`
|
|
704
|
+
: '';
|
|
705
|
+
const lines = [
|
|
706
|
+
`- **${g.type}** — ${who}${extra} (${g.notifications_count}) — ${g.latest_page_notification_at}`,
|
|
707
|
+
` Notification ID: ${g.most_recent_notification_id}`,
|
|
708
|
+
];
|
|
709
|
+
const status = g.status_id ? statuses.get(g.status_id) : undefined;
|
|
710
|
+
if (status) {
|
|
711
|
+
lines.push(` Status ${status.id}: ${stripHtml(status.content, 160) || '(no text)'}`);
|
|
712
|
+
}
|
|
713
|
+
return lines.join('\n');
|
|
714
|
+
})
|
|
715
|
+
.join('\n');
|
|
716
|
+
return text(`Notifications (${groups.length} group(s)):\n\n${formatted}`);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
return handleAppError(error, 'Error listing notifications', ACCOUNT_ERRORS);
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
export const dismissNotificationsTool = {
|
|
724
|
+
name: 'social_dismiss_notifications',
|
|
725
|
+
title: 'Dismiss Social Notifications',
|
|
726
|
+
annotations: {
|
|
727
|
+
readOnlyHint: false,
|
|
728
|
+
destructiveHint: true,
|
|
729
|
+
idempotentHint: true,
|
|
730
|
+
openWorldHint: false,
|
|
731
|
+
},
|
|
732
|
+
description: 'Dismiss one Nextcloud Social notification, or clear all of them when no id is given. Dismissed notifications cannot be brought back.',
|
|
733
|
+
inputSchema: z.object({
|
|
734
|
+
notification_id: z
|
|
735
|
+
.number()
|
|
736
|
+
.optional()
|
|
737
|
+
.describe('The notification to dismiss; omit to clear every notification'),
|
|
738
|
+
}),
|
|
739
|
+
handler: async (args = {}) => {
|
|
740
|
+
try {
|
|
741
|
+
if (args.notification_id === undefined) {
|
|
742
|
+
await fetchSocialAPI('/api/v1/notifications/clear', { method: 'POST' });
|
|
743
|
+
return text('Cleared all notifications.');
|
|
744
|
+
}
|
|
745
|
+
await fetchSocialAPI(`/api/v1/notifications/${args.notification_id}/dismiss`, {
|
|
746
|
+
method: 'POST',
|
|
747
|
+
});
|
|
748
|
+
return text(`Dismissed notification ${args.notification_id}.`);
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
return handleAppError(error, 'Error dismissing notifications', {
|
|
752
|
+
...ACCOUNT_ERRORS,
|
|
753
|
+
404: 'No such notification.',
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
// ── Search ──────────────────────────────────────────────────────────────────
|
|
759
|
+
export const searchTool = {
|
|
760
|
+
name: 'social_search',
|
|
761
|
+
title: 'Search the Fediverse',
|
|
762
|
+
annotations: READ_ONLY,
|
|
763
|
+
description: 'Search Nextcloud Social for accounts, hashtags or post content. Set resolve to true to fetch a handle or post URL this server has never seen — that contacts the remote server.',
|
|
764
|
+
inputSchema: z.object({
|
|
765
|
+
q: z.string().describe('The search query: text, a handle, a hashtag or a post URL'),
|
|
766
|
+
type: z
|
|
767
|
+
.enum(['accounts', 'hashtags', 'statuses'])
|
|
768
|
+
.optional()
|
|
769
|
+
.describe('Restrict the search to one kind of result; omit to search all three'),
|
|
770
|
+
limit: z.number().optional().describe(`Results per kind (default 20, max ${SEARCH_MAX})`),
|
|
771
|
+
resolve: z
|
|
772
|
+
.boolean()
|
|
773
|
+
.optional()
|
|
774
|
+
.describe('Fetch an unknown handle or URL from its home server (default false)'),
|
|
775
|
+
}),
|
|
776
|
+
handler: async (args) => {
|
|
777
|
+
try {
|
|
778
|
+
const results = await fetchSocialAPI('/api/v2/search', {
|
|
779
|
+
queryParams: {
|
|
780
|
+
q: args.q,
|
|
781
|
+
type: args.type ?? '',
|
|
782
|
+
limit: Math.min(args.limit ?? 20, SEARCH_MAX),
|
|
783
|
+
resolve: args.resolve ?? false,
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
const sections = [];
|
|
787
|
+
if (results.accounts?.length) {
|
|
788
|
+
sections.push(`Accounts (${results.accounts.length}):\n\n${results.accounts.map(formatAccount).join('\n\n')}`);
|
|
789
|
+
}
|
|
790
|
+
if (results.hashtags?.length) {
|
|
791
|
+
sections.push(`Hashtags (${results.hashtags.length}):\n\n` +
|
|
792
|
+
results.hashtags.map((h) => `- **#${h.name}** — ${h.url}`).join('\n'));
|
|
793
|
+
}
|
|
794
|
+
if (results.statuses?.length) {
|
|
795
|
+
sections.push(`Statuses (${results.statuses.length}):\n\n${results.statuses.map(formatStatus).join('\n\n')}`);
|
|
796
|
+
}
|
|
797
|
+
if (sections.length === 0) {
|
|
798
|
+
return text(`No results for "${args.q}".`);
|
|
799
|
+
}
|
|
800
|
+
return text(sections.join('\n\n---\n\n'));
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
return handleAppError(error, 'Error searching', ACCOUNT_ERRORS);
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
// ── Export ──────────────────────────────────────────────────────────────────
|
|
808
|
+
export const socialTools = [
|
|
809
|
+
homeTimelineTool,
|
|
810
|
+
publicTimelineTool,
|
|
811
|
+
hashtagTimelineTool,
|
|
812
|
+
listTimelineTool,
|
|
813
|
+
listSavedStatusesTool,
|
|
814
|
+
getStatusTool,
|
|
815
|
+
statusHistoryTool,
|
|
816
|
+
uploadMediaTool,
|
|
817
|
+
postStatusTool,
|
|
818
|
+
deleteStatusTool,
|
|
819
|
+
favouriteTool,
|
|
820
|
+
unfavouriteTool,
|
|
821
|
+
boostTool,
|
|
822
|
+
unboostTool,
|
|
823
|
+
bookmarkTool,
|
|
824
|
+
unbookmarkTool,
|
|
825
|
+
lookupAccountTool,
|
|
826
|
+
accountStatusesTool,
|
|
827
|
+
listAccountFollowsTool,
|
|
828
|
+
followAccountTool,
|
|
829
|
+
unfollowAccountTool,
|
|
830
|
+
listFollowRequestsTool,
|
|
831
|
+
respondFollowRequestTool,
|
|
832
|
+
listNotificationsTool,
|
|
833
|
+
dismissNotificationsTool,
|
|
834
|
+
searchTool,
|
|
835
|
+
];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiquila-mcp",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Nextcloud MCP server —
|
|
3
|
+
"version": "0.4.15",
|
|
4
|
+
"description": "Nextcloud MCP server — 342 tools: files, calendar, contacts, mail, Talk, Deck, photos, maps, notes, fediverse",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|