aiquila-mcp 0.3.23 → 0.3.25

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.
@@ -0,0 +1,41 @@
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 Passman REST API v2.
7
+ *
8
+ * Base path: /index.php/apps/passman/api/v2
9
+ */
10
+ export async function fetchPassmanAPI(endpoint, options = {}) {
11
+ const config = getNextcloudConfig();
12
+ const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
13
+ let url = `${config.url}/index.php/apps/passman/api/v2${endpoint}`;
14
+ if (options.queryParams) {
15
+ const params = new URLSearchParams(options.queryParams);
16
+ url += `?${params.toString()}`;
17
+ }
18
+ const headers = {
19
+ Authorization: `Basic ${auth}`,
20
+ 'OCS-APIRequest': 'true',
21
+ Accept: 'application/json',
22
+ };
23
+ let body;
24
+ if (options.body !== undefined) {
25
+ body = JSON.stringify(options.body);
26
+ headers['Content-Type'] = 'application/json';
27
+ }
28
+ const method = options.method ?? 'GET';
29
+ const t0 = Date.now();
30
+ const response = await fetch(url, { method, headers, body });
31
+ logger.trace({ method, url, status: response.status, ms: Date.now() - t0 }, '[passman] HTTP');
32
+ if (!response.ok) {
33
+ const text = await response.text();
34
+ throw new ApiError(response.status, response.statusText, text);
35
+ }
36
+ if (response.status === 200 &&
37
+ response.headers.get('content-type')?.includes('application/json')) {
38
+ return (await response.json());
39
+ }
40
+ return undefined;
41
+ }
@@ -43,6 +43,9 @@ import { projectsTools } from './tools/apps/projects.js';
43
43
  import { pollsTools } from './tools/apps/polls.js';
44
44
  import { formsTools } from './tools/apps/forms.js';
45
45
  import { textTools } from './tools/apps/text.js';
46
+ import { recommendationsTools } from './tools/apps/recommendations.js';
47
+ import { socialSharingTools } from './tools/apps/social-sharing.js';
48
+ import { passmanTools } from './tools/apps/passman.js';
46
49
  /**
47
50
  * Single source of truth for tool-to-Nextcloud-app mapping.
48
51
  *
@@ -77,6 +80,7 @@ export const TOOL_REGISTRY = [
77
80
  { category: 'news', appIds: ['news'], tools: newsTools },
78
81
  { category: 'mail', appIds: ['mail'], tools: mailTools },
79
82
  { category: 'deck', appIds: ['deck'], tools: deckTools },
83
+ { category: 'passman', appIds: ['passman'], tools: passmanTools },
80
84
  { category: 'cookbook', appIds: ['cookbook'], tools: cookbookTools },
81
85
  { category: 'maps', appIds: ['maps'], tools: mapsTools },
82
86
  { category: 'photos', appIds: ['photos'], tools: photosTools },
@@ -98,6 +102,20 @@ export const TOOL_REGISTRY = [
98
102
  appIds: ['terms_of_service'],
99
103
  tools: termsOfServiceTools,
100
104
  },
105
+ { category: 'recommendations', appIds: ['recommendations'], tools: recommendationsTools },
106
+ {
107
+ category: 'social_sharing',
108
+ appIds: [
109
+ 'socialsharing_email',
110
+ 'socialsharing_twitter',
111
+ 'socialsharing_facebook',
112
+ 'socialsharing_telegram',
113
+ 'socialsharing_whatsapp',
114
+ 'socialsharing_bluesky',
115
+ 'socialsharing_diaspora',
116
+ ],
117
+ tools: socialSharingTools,
118
+ },
101
119
  ];
102
120
  const ALL_CATEGORIES = new Set(TOOL_REGISTRY.map((e) => e.category));
103
121
  /**
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchPassmanAPI, } from '../../client/passman.js';
4
+ import { handleAppError } from '../error-utils.js';
5
+ /**
6
+ * Nextcloud Passman App Tools (password manager)
7
+ * Uses the Passman REST API v2 (/index.php/apps/passman/api/v2).
8
+ *
9
+ * IMPORTANT: Passman is zero-knowledge / end-to-end encrypted. Credential
10
+ * secrets (password, username, url, email, description, custom fields, OTP,
11
+ * tags, files) are encrypted client-side (SJCL AES-256) with a vault password
12
+ * that never reaches the server. This integration therefore exposes vault and
13
+ * credential *metadata only* (notably the plaintext `label`) and cannot decrypt
14
+ * or return any secret value.
15
+ */
16
+ const passmanStatusMap = {
17
+ 404: 'Not found.',
18
+ 403: 'Permission denied.',
19
+ 400: (e) => `Bad request: ${e.responseBody}`,
20
+ };
21
+ const E2E_NOTE = 'Note: secret values (password, username, url, etc.) are end-to-end encrypted and cannot be retrieved by this server.';
22
+ function formatVault(vault) {
23
+ const count = vault.credentials?.length ?? undefined;
24
+ const parts = [`guid: ${vault.guid}`];
25
+ if (count !== undefined)
26
+ parts.push(`${count} credentials`);
27
+ parts.push(`created: ${new Date(vault.created * 1000).toISOString()}`);
28
+ if (vault.last_access) {
29
+ parts.push(`last access: ${new Date(vault.last_access * 1000).toISOString()}`);
30
+ }
31
+ return `${vault.name} (${parts.join(', ')})`;
32
+ }
33
+ function hasValue(v) {
34
+ if (v === undefined || v === null || v === '')
35
+ return false;
36
+ if (Array.isArray(v))
37
+ return v.length > 0;
38
+ return true;
39
+ }
40
+ function formatCredential(cred) {
41
+ const flags = [];
42
+ if (hasValue(cred.url))
43
+ flags.push('has url');
44
+ if (hasValue(cred.tags))
45
+ flags.push('tagged');
46
+ if (cred.hidden)
47
+ flags.push('hidden');
48
+ if (cred.compromised)
49
+ flags.push('compromised');
50
+ if (cred.expire_time) {
51
+ flags.push(`expires: ${new Date(cred.expire_time * 1000).toISOString()}`);
52
+ }
53
+ const meta = [`guid: ${cred.guid}`, ...flags].join(', ');
54
+ return `[${cred.credential_id}] ${cred.label} (${meta})`;
55
+ }
56
+ export const listVaultsTool = {
57
+ name: 'passman_list_vaults',
58
+ description: `List all Passman password vaults. Returns name, guid, creation/last-access time and credential count. ${E2E_NOTE}`,
59
+ inputSchema: z.object({}),
60
+ handler: async () => {
61
+ try {
62
+ const vaults = await fetchPassmanAPI('/vaults');
63
+ if (!vaults || vaults.length === 0) {
64
+ return { content: [{ type: 'text', text: 'No vaults found.' }] };
65
+ }
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Vaults (${vaults.length}):\n\n${vaults.map(formatVault).join('\n')}\n\n${E2E_NOTE}`,
71
+ },
72
+ ],
73
+ };
74
+ }
75
+ catch (error) {
76
+ return handleAppError(error, 'Error listing vaults', passmanStatusMap);
77
+ }
78
+ },
79
+ };
80
+ export const listCredentialsTool = {
81
+ name: 'passman_list_credentials',
82
+ description: `List credentials (metadata only) in a Passman vault. Returns each credential's label, id, guid and non-secret flags. ${E2E_NOTE}`,
83
+ inputSchema: z.object({
84
+ vault_guid: z.string().describe('Vault GUID (from passman_list_vaults)'),
85
+ }),
86
+ handler: async (args) => {
87
+ try {
88
+ const vault = await fetchPassmanAPI(`/vaults/${encodeURIComponent(args.vault_guid)}`);
89
+ const creds = vault.credentials ?? [];
90
+ if (creds.length === 0) {
91
+ return { content: [{ type: 'text', text: 'No credentials in this vault.' }] };
92
+ }
93
+ return {
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: `Credentials in "${vault.name}" (${creds.length}):\n\n${creds
98
+ .map(formatCredential)
99
+ .join('\n')}\n\n${E2E_NOTE}`,
100
+ },
101
+ ],
102
+ };
103
+ }
104
+ catch (error) {
105
+ return handleAppError(error, 'Error listing credentials', passmanStatusMap);
106
+ }
107
+ },
108
+ };
109
+ export const getCredentialInfoTool = {
110
+ name: 'passman_get_credential_info',
111
+ description: `Get non-secret metadata for a single Passman credential (label, id, timestamps, flags). ${E2E_NOTE}`,
112
+ inputSchema: z.object({
113
+ vault_guid: z.string().describe('Vault GUID (from passman_list_vaults)'),
114
+ credential_guid: z.string().describe('Credential GUID (from passman_list_credentials)'),
115
+ }),
116
+ handler: async (args) => {
117
+ try {
118
+ const vault = await fetchPassmanAPI(`/vaults/${encodeURIComponent(args.vault_guid)}`);
119
+ const cred = (vault.credentials ?? []).find((c) => c.guid === args.credential_guid);
120
+ if (!cred) {
121
+ return {
122
+ content: [{ type: 'text', text: 'Credential not found in this vault.' }],
123
+ isError: true,
124
+ };
125
+ }
126
+ const lines = [
127
+ `# ${cred.label}`,
128
+ '',
129
+ `ID: ${cred.credential_id}`,
130
+ `GUID: ${cred.guid}`,
131
+ `Vault: ${vault.name}`,
132
+ `Created: ${new Date(cred.created * 1000).toISOString()}`,
133
+ `Changed: ${new Date(cred.changed * 1000).toISOString()}`,
134
+ `Has URL: ${hasValue(cred.url)}`,
135
+ `Tagged: ${hasValue(cred.tags)}`,
136
+ `Hidden: ${Boolean(cred.hidden)}`,
137
+ `Compromised: ${Boolean(cred.compromised)}`,
138
+ ];
139
+ if (cred.expire_time) {
140
+ lines.push(`Expires: ${new Date(cred.expire_time * 1000).toISOString()}`);
141
+ }
142
+ lines.push('', E2E_NOTE);
143
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
144
+ }
145
+ catch (error) {
146
+ return handleAppError(error, 'Error getting credential info', passmanStatusMap);
147
+ }
148
+ },
149
+ };
150
+ export const passmanTools = [listVaultsTool, listCredentialsTool, getCredentialInfoTool];
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchOCS } from '../../client/ocs.js';
4
+ function formatTime(ts) {
5
+ if (!ts)
6
+ return '';
7
+ return new Date(ts * 1000).toISOString();
8
+ }
9
+ function formatRecommendations(items) {
10
+ return items
11
+ .map((f) => {
12
+ const dir = f.directory && f.directory !== '/' ? f.directory.replace(/\/$/, '') : '';
13
+ const path = `${dir}/${f.name}`;
14
+ const time = f.timestamp ? ` (${formatTime(f.timestamp)})` : '';
15
+ const reason = f.reason ? `\n reason: ${f.reason}` : '';
16
+ return `- ${path}${time}${reason}`;
17
+ })
18
+ .join('\n');
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // list_recommendations
22
+ // ---------------------------------------------------------------------------
23
+ export const listRecommendationsTool = {
24
+ name: 'list_recommendations',
25
+ description: 'List files Nextcloud recommends for the configured user (e.g. recently or frequently ' +
26
+ 'accessed, recently shared, or files in active folders), including the reason each file ' +
27
+ "is recommended. Respects the user's recommendations setting.",
28
+ inputSchema: z.object({}),
29
+ handler: async () => {
30
+ try {
31
+ const result = await fetchOCS('/ocs/v2.php/apps/recommendations/api/v1/recommendations');
32
+ const data = result.ocs.data;
33
+ const items = data.recommendations ?? [];
34
+ if (!data.enabled && items.length === 0) {
35
+ return {
36
+ content: [
37
+ {
38
+ type: 'text',
39
+ text: 'Recommendations are disabled for this user.',
40
+ },
41
+ ],
42
+ };
43
+ }
44
+ if (items.length === 0) {
45
+ return {
46
+ content: [{ type: 'text', text: 'No recommended files.' }],
47
+ };
48
+ }
49
+ return {
50
+ content: [
51
+ {
52
+ type: 'text',
53
+ text: `Recommended files (${items.length}):\n${formatRecommendations(items)}`,
54
+ },
55
+ ],
56
+ };
57
+ }
58
+ catch (error) {
59
+ return {
60
+ content: [
61
+ {
62
+ type: 'text',
63
+ text: `Error listing recommendations: ${error instanceof Error ? error.message : String(error)}`,
64
+ },
65
+ ],
66
+ isError: true,
67
+ };
68
+ }
69
+ },
70
+ };
71
+ // ---------------------------------------------------------------------------
72
+ // Export
73
+ // ---------------------------------------------------------------------------
74
+ export const recommendationsTools = [listRecommendationsTool];
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ import { fetchOCS } from '../../client/ocs.js';
4
+ import { getNextcloudConfig } from '../types.js';
5
+ import { handleAppError } from '../error-utils.js';
6
+ const SHARE_TYPE_PUBLIC_LINK = 3;
7
+ /** Default share message used by the upstream apps. */
8
+ const DEFAULT_MESSAGE = 'I shared a file with you';
9
+ const NETWORKS = [
10
+ {
11
+ key: 'email',
12
+ appId: 'socialsharing_email',
13
+ build: (link, msg) => `mailto:?subject=${msg}&body=${encodeURIComponent(link)}`,
14
+ },
15
+ {
16
+ key: 'twitter',
17
+ appId: 'socialsharing_twitter',
18
+ build: (link) => `https://twitter.com/intent/tweet?url=${encodeURIComponent(link)}`,
19
+ },
20
+ {
21
+ key: 'facebook',
22
+ appId: 'socialsharing_facebook',
23
+ build: (link) => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(link)}`,
24
+ },
25
+ {
26
+ key: 'telegram',
27
+ appId: 'socialsharing_telegram',
28
+ build: (link, msg) => `https://telegram.me/share/url?url=${encodeURIComponent(link)}&text=${msg}`,
29
+ },
30
+ {
31
+ key: 'whatsapp',
32
+ appId: 'socialsharing_whatsapp',
33
+ build: (link, msg) => `whatsapp://send?text=${msg}:%0A%0A${encodeURIComponent(link)}`,
34
+ },
35
+ {
36
+ key: 'bluesky',
37
+ appId: 'socialsharing_bluesky',
38
+ build: (link, msg) => `https://bsky.app/intent/compose?text=${msg}:%0A%0A${encodeURIComponent(link)}`,
39
+ },
40
+ {
41
+ key: 'diaspora',
42
+ appId: 'socialsharing_diaspora',
43
+ build: (link) => `https://share.diasporafoundation.org/?url=${encodeURIComponent(link)}`,
44
+ },
45
+ ];
46
+ /** Fetch the set of enabled Nextcloud app ids. */
47
+ async function getEnabledApps() {
48
+ const result = await fetchOCS('/ocs/v2.php/cloud/apps', {
49
+ queryParams: { filter: 'enabled' },
50
+ });
51
+ return new Set(result.ocs.data.apps);
52
+ }
53
+ /** Resolve a public-link share by id or path. */
54
+ async function resolvePublicShare(args) {
55
+ if (args.share_id !== undefined) {
56
+ const result = await fetchOCS(`/ocs/v2.php/apps/files_sharing/api/v1/shares/${args.share_id}`);
57
+ // The single-share endpoint returns an array with one entry.
58
+ const data = result.ocs.data;
59
+ const share = Array.isArray(data) ? data[0] : data;
60
+ if (!share)
61
+ throw new Error(`Share ${args.share_id} not found`);
62
+ return share;
63
+ }
64
+ const result = await fetchOCS('/ocs/v2.php/apps/files_sharing/api/v1/shares', {
65
+ queryParams: { path: args.path },
66
+ });
67
+ const link = result.ocs.data.find((s) => s.share_type === SHARE_TYPE_PUBLIC_LINK && s.token);
68
+ if (!link) {
69
+ throw new Error(`No public link share found for path "${args.path}". Create one first.`);
70
+ }
71
+ return link;
72
+ }
73
+ export const generateSocialShareLinksTool = {
74
+ name: 'generate_social_share_links',
75
+ description: 'Generate social-network share URLs (email, X/Twitter, Facebook, Telegram, WhatsApp, ' +
76
+ 'Bluesky, Diaspora) for an existing Nextcloud public-link share. Only networks whose ' +
77
+ 'socialsharing_<network> app is enabled are returned. Provide either share_id or path.',
78
+ inputSchema: z.object({
79
+ share_id: z.number().optional().describe('ID of an existing public-link share'),
80
+ path: z
81
+ .string()
82
+ .optional()
83
+ .describe('File/folder path; its public-link share is used (must already exist)'),
84
+ networks: z
85
+ .array(z.string())
86
+ .optional()
87
+ .describe('Optional filter, e.g. ["twitter","email"]. Defaults to all enabled networks.'),
88
+ }),
89
+ handler: async (args) => {
90
+ if (args.share_id === undefined && !args.path) {
91
+ return {
92
+ content: [{ type: 'text', text: 'Provide either share_id or path.' }],
93
+ isError: true,
94
+ };
95
+ }
96
+ try {
97
+ const share = await resolvePublicShare(args);
98
+ if (share.share_type !== SHARE_TYPE_PUBLIC_LINK || !share.token) {
99
+ return {
100
+ content: [
101
+ {
102
+ type: 'text',
103
+ text: 'The referenced share is not a public link (no shareable token).',
104
+ },
105
+ ],
106
+ isError: true,
107
+ };
108
+ }
109
+ const { url } = getNextcloudConfig();
110
+ const link = share.url || `${url}/s/${share.token}`;
111
+ const message = encodeURIComponent(DEFAULT_MESSAGE);
112
+ const filter = args.networks ? new Set(args.networks.map((n) => n.toLowerCase())) : null;
113
+ const enabledApps = await getEnabledApps();
114
+ const lines = NETWORKS.filter((n) => enabledApps.has(n.appId))
115
+ .filter((n) => !filter || filter.has(n.key))
116
+ .map((n) => `${n.key}: ${n.build(link, message)}`);
117
+ if (lines.length === 0) {
118
+ return {
119
+ content: [
120
+ {
121
+ type: 'text',
122
+ text: 'No matching social sharing networks are available. Install/enable a ' +
123
+ 'socialsharing_<network> app (or adjust the networks filter).',
124
+ },
125
+ ],
126
+ };
127
+ }
128
+ return {
129
+ content: [
130
+ {
131
+ type: 'text',
132
+ text: `Social share links for ${link}:\n${lines.join('\n')}`,
133
+ },
134
+ ],
135
+ };
136
+ }
137
+ catch (error) {
138
+ return handleAppError(error, 'Error generating social share links');
139
+ }
140
+ },
141
+ };
142
+ export const socialSharingTools = [generateSocialShareLinksTool];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "Nextcloud MCP server — files, calendar, contacts, mail, maps, notes, tasks & 120+ more tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",