aiquila-mcp 0.4.3 → 0.4.5

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
@@ -92,6 +92,8 @@ See the [Docker setup guide](https://github.com/elgorro/aiquila/blob/main/docs/m
92
92
  | `MCP_AUTH_ENABLED` | No | `true` to enable OAuth for remote clients |
93
93
  | `MCP_AUTH_SECRET` | If auth | `openssl rand -hex 32` |
94
94
  | `MCP_AUTH_ISSUER` | If auth | public HTTPS URL of this server |
95
+ | `MCP_ALLOWED_HOSTS` | No | extra hostnames for DNS rebinding protection |
96
+ | `MCP_CORS_ORIGINS` | No | extra browser origins allowed via CORS |
95
97
  | `LOG_LEVEL` | No | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
96
98
 
97
99
  ## Requirements
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * Stylesheet for the OAuth login page.
4
+ *
5
+ * Served as a standalone document at LOGIN_STYLESHEET_PATH rather than inlined
6
+ * in a <style> block, so the login page's CSP can drop 'unsafe-inline' entirely.
7
+ * Keep this free of any interpolated/user-supplied value — it is served verbatim.
8
+ */
9
+ export const LOGIN_STYLESHEET = `body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
10
+ .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
11
+ h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
12
+ .consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
13
+ .consent-banner strong { color: #0082c9; }
14
+ .consent-banner .detail { display: block; margin-top: .25rem; }
15
+ .consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
16
+ label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
17
+ input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
18
+ button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
19
+ button:hover { background: #006fa3; }
20
+ .error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
21
+ `;
22
+ /** Public path the login page links its stylesheet from. */
23
+ export const LOGIN_STYLESHEET_PATH = '/auth/login.css';
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import { renderLoginForm } from './provider.js';
2
+ import { renderLoginForm, applySecurityHeaders } from './provider.js';
3
3
  import { logger } from '../logger.js';
4
4
  export function loginHandler(provider) {
5
5
  return async (req, res) => {
@@ -11,7 +11,7 @@ export function loginHandler(provider) {
11
11
  return;
12
12
  }
13
13
  if (!username || !password || !client_id || !redirect_uri || !code_challenge) {
14
- res
14
+ applySecurityHeaders(res)
15
15
  .status(400)
16
16
  .type('html')
17
17
  .send(renderLoginForm({
@@ -26,7 +26,7 @@ export function loginHandler(provider) {
26
26
  }
27
27
  const client = await provider.clientsStore.getClient(client_id);
28
28
  if (!client) {
29
- res
29
+ applySecurityHeaders(res)
30
30
  .status(400)
31
31
  .type('html')
32
32
  .send(renderLoginForm({
@@ -40,7 +40,7 @@ export function loginHandler(provider) {
40
40
  return;
41
41
  }
42
42
  if (!client.redirect_uris.map(String).includes(redirect_uri)) {
43
- res
43
+ applySecurityHeaders(res)
44
44
  .status(400)
45
45
  .type('html')
46
46
  .send(renderLoginForm({
@@ -64,7 +64,7 @@ export function loginHandler(provider) {
64
64
  });
65
65
  if (!ncResp.ok) {
66
66
  logger.warn({ user: username, status: ncResp.status }, '[auth] Login failed');
67
- res
67
+ applySecurityHeaders(res)
68
68
  .status(200)
69
69
  .type('html')
70
70
  .send(renderLoginForm({
@@ -95,7 +95,7 @@ export function loginHandler(provider) {
95
95
  }
96
96
  catch (err) {
97
97
  logger.error({ user: username, err }, '[auth] Login error');
98
- res
98
+ applySecurityHeaders(res)
99
99
  .status(200)
100
100
  .type('html')
101
101
  .send(renderLoginForm({
@@ -3,6 +3,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
3
3
  import { SignJWT, jwtVerify } from 'jose';
4
4
  import { InvalidGrantError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
5
5
  import { ClientsStore, CodeStore, RefreshStore } from './store.js';
6
+ import { LOGIN_STYLESHEET_PATH } from './login-page-css.js';
6
7
  import { logger } from '../logger.js';
7
8
  // --- JWT helpers (HMAC-SHA256 via jose) ---
8
9
  async function signJwt(payload, secret, expiresInSecs) {
@@ -37,6 +38,22 @@ function escapeHtml(s) {
37
38
  .replace(/"/g, '&quot;')
38
39
  .replace(/'/g, '&#39;');
39
40
  }
41
+ /**
42
+ * Security headers for every HTML response served by the OAuth login flow.
43
+ *
44
+ * The page has no inline <script> and no inline styles (the stylesheet is served
45
+ * separately from LOGIN_STYLESHEET_PATH), so the policy can deny everything by
46
+ * default and allow only same-origin styles.
47
+ */
48
+ export const LOGIN_PAGE_CSP = "frame-ancestors 'none'; default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'";
49
+ /** Applies the login-flow security headers to a response. Returns the response for chaining. */
50
+ export function applySecurityHeaders(res) {
51
+ return res
52
+ .set('X-Frame-Options', 'DENY')
53
+ .set('X-Content-Type-Options', 'nosniff')
54
+ .set('Referrer-Policy', 'no-referrer')
55
+ .set('Content-Security-Policy', LOGIN_PAGE_CSP);
56
+ }
40
57
  export function renderLoginForm(opts) {
41
58
  return `<!DOCTYPE html>
42
59
  <html lang="en">
@@ -44,21 +61,7 @@ export function renderLoginForm(opts) {
44
61
  <meta charset="UTF-8">
45
62
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
46
63
  <title>AIquila – Sign in with Nextcloud</title>
47
- <style>
48
- body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
49
- .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
50
- h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
51
- p.sub { color: #555; font-size: .9rem; margin: 0 0 1.5rem; }
52
- .consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
53
- .consent-banner strong { color: #0082c9; }
54
- .consent-banner .detail { display: block; margin-top: .25rem; }
55
- .consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
56
- label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
57
- input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
58
- button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
59
- button:hover { background: #006fa3; }
60
- .error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
61
- </style>
64
+ <link rel="stylesheet" href="${LOGIN_STYLESHEET_PATH}">
62
65
  </head>
63
66
  <body>
64
67
  <div class="card">
@@ -96,9 +99,7 @@ export class NextcloudOAuthProvider {
96
99
  return this._clientsStore;
97
100
  }
98
101
  async authorize(client, params, res) {
99
- res
100
- .set('X-Frame-Options', 'DENY')
101
- .set('Content-Security-Policy', "frame-ancestors 'none'; default-src 'self'; style-src 'unsafe-inline'")
102
+ applySecurityHeaders(res)
102
103
  .status(200)
103
104
  .type('html')
104
105
  .send(renderLoginForm({
@@ -32,33 +32,9 @@ function buildHeaders(auth, hasBody) {
32
32
  }
33
33
  return headers;
34
34
  }
35
- /**
36
- * Fetch from the CORS-enabled external Maps API.
37
- * Base: /apps/maps/api/1.0
38
- * Used for: favorites, devices
39
- */
40
- export async function fetchMapsExternalAPI(endpoint, options = {}) {
41
- const config = getNextcloudConfig();
42
- const auth = Buffer.from(`${config.user}:${config.password}`).toString('base64');
43
- const url = buildUrl(`${config.url}/apps/maps/api/1.0`, endpoint, options.queryParams);
44
- const body = options.body ? JSON.stringify(options.body) : undefined;
45
- const t0 = Date.now();
46
- const response = await fetch(url, {
47
- method: options.method || 'GET',
48
- headers: buildHeaders(auth, !!options.body),
49
- body,
50
- });
51
- logger.trace({ method: options.method || 'GET', url, status: response.status, ms: Date.now() - t0 }, '[nc] HTTP');
52
- if (!response.ok) {
53
- const text = await response.text().catch(() => '');
54
- throw new Error(`Maps API ${response.status}: ${text || response.statusText}`);
55
- }
56
- return (await response.json());
57
- }
58
35
  /**
59
36
  * Fetch from the internal Maps controller endpoints.
60
37
  * Base: /apps/maps
61
- * Used for: tracks, photos, my maps, routing, import/export
62
38
  */
63
39
  export async function fetchMapsAPI(endpoint, options = {}) {
64
40
  const config = getNextcloudConfig();
@@ -1,6 +1,29 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import { z } from 'zod';
3
- import { fetchMapsExternalAPI, fetchMapsAPI } from '../../client/maps.js';
3
+ import { fetchMapsAPI } from '../../client/maps.js';
4
+ import { handleAppError } from '../error-utils.js';
5
+ /**
6
+ * Nextcloud Maps App Tools
7
+ * Manages favorites, favorite/device sharing, devices, tracks, photos, contacts,
8
+ * custom maps, routing, and import/export.
9
+ *
10
+ * Verified against Nextcloud Maps 1.8.0.
11
+ */
12
+ // ── Shared helpers ──────────────────────────────────────────────────────────
13
+ /**
14
+ * Most Maps controller methods accept an optional myMapId to operate on a custom
15
+ * "My Map" (a folder holding JSON files) instead of the user's default DB store.
16
+ */
17
+ const MY_MAP_ID_SCHEMA = z
18
+ .number()
19
+ .optional()
20
+ .describe("ID of a custom map to scope the operation to; omit for the user's default map");
21
+ function myMapIdQuery(myMapId) {
22
+ return myMapId === undefined ? {} : { myMapId: String(myMapId) };
23
+ }
24
+ function errorResult(what, error) {
25
+ return handleAppError(error, `Error ${what}`);
26
+ }
4
27
  // ── Formatters ──────────────────────────────────────────────────────────────
5
28
  function formatFavorite(f) {
6
29
  const lines = [`- **${f.name || '(unnamed)'}** (ID: ${f.id})`];
@@ -9,8 +32,9 @@ function formatFavorite(f) {
9
32
  lines.push(` Category: ${f.category}`);
10
33
  if (f.comment)
11
34
  lines.push(` Comment: ${f.comment}`);
12
- if (f.extensions)
13
- lines.push(` Extensions: ${f.extensions}`);
35
+ const extensions = Array.isArray(f.extensions) ? f.extensions.join(', ') : f.extensions;
36
+ if (extensions)
37
+ lines.push(` Extensions: ${extensions}`);
14
38
  lines.push(` Created: ${new Date(f.date_created * 1000).toISOString()}`);
15
39
  return lines.join('\n');
16
40
  }
@@ -23,11 +47,11 @@ function formatDevice(d) {
23
47
  function formatDevicePoint(p) {
24
48
  const parts = [` ${p.lat}, ${p.lng}`];
25
49
  parts.push(`@ ${new Date(p.timestamp * 1000).toISOString()}`);
26
- if (p.altitude !== undefined)
50
+ if (p.altitude != null)
27
51
  parts.push(`alt: ${p.altitude}m`);
28
- if (p.accuracy !== undefined)
52
+ if (p.accuracy != null)
29
53
  parts.push(`acc: ${p.accuracy}m`);
30
- if (p.battery !== undefined)
54
+ if (p.battery != null)
31
55
  parts.push(`bat: ${p.battery}%`);
32
56
  return `- ${parts.join(' | ')}`;
33
57
  }
@@ -53,13 +77,45 @@ function formatNonLocalizedPhoto(p) {
53
77
  lines.push(` Taken: ${new Date(p.dateTaken * 1000).toISOString()}`);
54
78
  return lines.join('\n');
55
79
  }
80
+ function formatFavoriteShare(s) {
81
+ const lines = [`- **${s.category}**`];
82
+ lines.push(` Token: ${s.token}`);
83
+ if (s.owner)
84
+ lines.push(` Owner: ${s.owner}`);
85
+ return lines.join('\n');
86
+ }
87
+ function formatDeviceShare(s) {
88
+ const deviceId = s.deviceId ?? s.device_id;
89
+ const lines = [`- Share token: ${s.token}`];
90
+ if (deviceId !== undefined)
91
+ lines.push(` Device ID: ${deviceId}`);
92
+ if (s.timestampFrom !== undefined) {
93
+ lines.push(` From: ${new Date(s.timestampFrom * 1000).toISOString()}`);
94
+ }
95
+ if (s.timestampTo !== undefined) {
96
+ lines.push(` To: ${new Date(s.timestampTo * 1000).toISOString()}`);
97
+ }
98
+ return lines.join('\n');
99
+ }
100
+ function formatContact(c) {
101
+ const lines = [`- **${c.FN || '(unnamed)'}**`];
102
+ lines.push(` Book ID: ${c.BOOKID} | URI: ${c.URI}`);
103
+ if (c.GEO)
104
+ lines.push(` Coords: ${c.GEO}`);
105
+ const adrType = Array.isArray(c.ADRTYPE) ? c.ADRTYPE.join('/') : c.ADRTYPE;
106
+ if (c.ADR)
107
+ lines.push(` Address: ${c.ADR}${adrType ? ` (${adrType})` : ''}`);
108
+ if (c.GROUPS)
109
+ lines.push(` Groups: ${c.GROUPS}`);
110
+ return lines.join('\n');
111
+ }
56
112
  function formatMyMap(m) {
57
113
  const entries = Object.entries(m)
58
114
  .filter(([k]) => k !== 'id')
59
- .map(([k, v]) => ` ${k}: ${v}`);
115
+ .map(([k, v]) => ` ${k}: ${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`);
60
116
  return [`- Map ID: ${m.id}`, ...entries].join('\n');
61
117
  }
62
- // ── Favorites Tools (External API) ──────────────────────────────────────────
118
+ // ── Favorites Tools ─────────────────────────────────────────────────────────
63
119
  export const listMapFavoritesTool = {
64
120
  name: 'list_map_favorites',
65
121
  title: 'List Map Favorites',
@@ -69,19 +125,23 @@ export const listMapFavoritesTool = {
69
125
  idempotentHint: true,
70
126
  openWorldHint: false,
71
127
  },
72
- description: 'List map favorites (saved locations/pins) from Nextcloud Maps. Optionally filter by modification time.',
128
+ description: 'List map favorites (saved locations/pins) from Nextcloud Maps. Optionally filter by modification time or scope to a custom map.',
73
129
  inputSchema: z.object({
74
130
  pruneBefore: z
75
131
  .number()
76
132
  .optional()
77
133
  .describe('Unix timestamp — only return favorites modified after this time'),
134
+ myMapId: MY_MAP_ID_SCHEMA,
78
135
  }),
79
136
  handler: async (args) => {
80
137
  try {
81
- const queryParams = {};
82
- if (args.pruneBefore !== undefined)
83
- queryParams['pruneBefore'] = String(args.pruneBefore);
84
- const result = await fetchMapsExternalAPI('/favorites', { queryParams });
138
+ let result = await fetchMapsAPI('/favorites', {
139
+ queryParams: myMapIdQuery(args.myMapId),
140
+ });
141
+ if (args.pruneBefore !== undefined) {
142
+ const cutoff = args.pruneBefore;
143
+ result = result.filter((f) => (f.date_modified ?? 0) > cutoff);
144
+ }
85
145
  if (result.length === 0) {
86
146
  return { content: [{ type: 'text', text: 'No map favorites found.' }] };
87
147
  }
@@ -93,15 +153,7 @@ export const listMapFavoritesTool = {
93
153
  };
94
154
  }
95
155
  catch (error) {
96
- return {
97
- content: [
98
- {
99
- type: 'text',
100
- text: `Error listing map favorites: ${error instanceof Error ? error.message : String(error)}`,
101
- },
102
- ],
103
- isError: true,
104
- };
156
+ return errorResult('listing map favorites', error);
105
157
  }
106
158
  },
107
159
  };
@@ -122,6 +174,7 @@ export const createMapFavoriteTool = {
122
174
  category: z.string().optional().describe("Category name (e.g. 'Restaurant', 'Home')"),
123
175
  comment: z.string().optional().describe('A comment or note'),
124
176
  extensions: z.string().optional().describe('Extra data as a string'),
177
+ myMapId: MY_MAP_ID_SCHEMA,
125
178
  }),
126
179
  handler: async (args) => {
127
180
  try {
@@ -134,7 +187,10 @@ export const createMapFavoriteTool = {
134
187
  body.comment = args.comment;
135
188
  if (args.extensions !== undefined)
136
189
  body.extensions = args.extensions;
137
- const result = await fetchMapsExternalAPI('/favorites', {
190
+ if (args.myMapId !== undefined)
191
+ body.myMapId = args.myMapId;
192
+ // Note: the create route is /favorite (singular); /favorites is the batch route.
193
+ const result = await fetchMapsAPI('/favorite', {
138
194
  method: 'POST',
139
195
  body,
140
196
  });
@@ -148,15 +204,7 @@ export const createMapFavoriteTool = {
148
204
  };
149
205
  }
150
206
  catch (error) {
151
- return {
152
- content: [
153
- {
154
- type: 'text',
155
- text: `Error creating map favorite: ${error instanceof Error ? error.message : String(error)}`,
156
- },
157
- ],
158
- isError: true,
159
- };
207
+ return errorResult('creating map favorite', error);
160
208
  }
161
209
  },
162
210
  };
@@ -178,23 +226,37 @@ export const updateMapFavoriteTool = {
178
226
  category: z.string().optional().describe('New category'),
179
227
  comment: z.string().optional().describe('New comment'),
180
228
  extensions: z.string().optional().describe('New extensions data'),
229
+ myMapId: MY_MAP_ID_SCHEMA,
181
230
  }),
182
231
  handler: async (args) => {
183
232
  try {
184
- const body = {};
233
+ // The controller types lat/lng as non-nullable floats, so they must always be
234
+ // sent — look up the current coordinates when the caller omits either one.
235
+ let lat = args.lat;
236
+ let lng = args.lng;
237
+ if (lat === undefined || lng === undefined) {
238
+ const existing = await fetchMapsAPI('/favorites', {
239
+ queryParams: myMapIdQuery(args.myMapId),
240
+ });
241
+ const current = existing.find((f) => f.id === args.id);
242
+ if (!current) {
243
+ throw new Error(`No favorite with ID ${args.id}`);
244
+ }
245
+ lat = lat ?? current.lat;
246
+ lng = lng ?? current.lng;
247
+ }
248
+ const body = { lat, lng };
185
249
  if (args.name !== undefined)
186
250
  body.name = args.name;
187
- if (args.lat !== undefined)
188
- body.lat = args.lat;
189
- if (args.lng !== undefined)
190
- body.lng = args.lng;
191
251
  if (args.category !== undefined)
192
252
  body.category = args.category;
193
253
  if (args.comment !== undefined)
194
254
  body.comment = args.comment;
195
255
  if (args.extensions !== undefined)
196
256
  body.extensions = args.extensions;
197
- const result = await fetchMapsExternalAPI(`/favorites/${args.id}`, {
257
+ if (args.myMapId !== undefined)
258
+ body.myMapId = args.myMapId;
259
+ const result = await fetchMapsAPI(`/favorites/${args.id}`, {
198
260
  method: 'PUT',
199
261
  body,
200
262
  });
@@ -208,52 +270,209 @@ export const updateMapFavoriteTool = {
208
270
  };
209
271
  }
210
272
  catch (error) {
273
+ return errorResult(`updating map favorite ${args.id}`, error);
274
+ }
275
+ },
276
+ };
277
+ export const deleteMapFavoriteTool = {
278
+ name: 'delete_map_favorite',
279
+ title: 'Delete Map Favorite',
280
+ annotations: {
281
+ readOnlyHint: false,
282
+ destructiveHint: true,
283
+ idempotentHint: true,
284
+ openWorldHint: false,
285
+ },
286
+ description: 'Delete a map favorite by its ID. This action is irreversible.',
287
+ inputSchema: z.object({
288
+ id: z.number().describe('Favorite ID to delete'),
289
+ myMapId: MY_MAP_ID_SCHEMA,
290
+ }),
291
+ handler: async (args) => {
292
+ try {
293
+ await fetchMapsAPI(`/favorites/${args.id}`, {
294
+ method: 'DELETE',
295
+ queryParams: myMapIdQuery(args.myMapId),
296
+ });
297
+ return {
298
+ content: [{ type: 'text', text: `Favorite ${args.id} deleted.` }],
299
+ };
300
+ }
301
+ catch (error) {
302
+ return errorResult(`deleting map favorite ${args.id}`, error);
303
+ }
304
+ },
305
+ };
306
+ export const renameMapFavoriteCategoryTool = {
307
+ name: 'rename_map_favorite_category',
308
+ title: 'Rename Map Favorite Category',
309
+ annotations: {
310
+ readOnlyHint: false,
311
+ destructiveHint: true,
312
+ idempotentHint: true,
313
+ openWorldHint: false,
314
+ },
315
+ description: 'Rename one or more favorite categories. All favorites in the listed categories are moved to the new name.',
316
+ inputSchema: z.object({
317
+ categories: z.array(z.string()).min(1).describe('Existing category names to rename'),
318
+ newName: z.string().describe('New category name'),
319
+ myMapId: MY_MAP_ID_SCHEMA,
320
+ }),
321
+ handler: async (args) => {
322
+ try {
323
+ const body = {
324
+ categories: args.categories,
325
+ newName: args.newName,
326
+ };
327
+ if (args.myMapId !== undefined)
328
+ body.myMapId = args.myMapId;
329
+ await fetchMapsAPI('/favorites-category', { method: 'PUT', body });
211
330
  return {
212
331
  content: [
213
332
  {
214
333
  type: 'text',
215
- text: `Error updating map favorite ${args.id}: ${error instanceof Error ? error.message : String(error)}`,
334
+ text: `Renamed ${args.categories.length} categor${args.categories.length === 1 ? 'y' : 'ies'} (${args.categories.join(', ')}) to "${args.newName}".`,
216
335
  },
217
336
  ],
218
- isError: true,
219
337
  };
220
338
  }
339
+ catch (error) {
340
+ return errorResult('renaming favorite categories', error);
341
+ }
221
342
  },
222
343
  };
223
- export const deleteMapFavoriteTool = {
224
- name: 'delete_map_favorite',
225
- title: 'Delete Map Favorite',
344
+ // ── Favorite Category Sharing Tools ─────────────────────────────────────────
345
+ export const listSharedMapCategoriesTool = {
346
+ name: 'list_shared_map_categories',
347
+ title: 'List Shared Map Categories',
348
+ annotations: {
349
+ readOnlyHint: true,
350
+ destructiveHint: false,
351
+ idempotentHint: true,
352
+ openWorldHint: false,
353
+ },
354
+ description: 'List favorite categories that are shared via a public link.',
355
+ inputSchema: z.object({
356
+ myMapId: MY_MAP_ID_SCHEMA,
357
+ }),
358
+ handler: async (args) => {
359
+ try {
360
+ const result = await fetchMapsAPI('/favorites-category/shared', {
361
+ queryParams: myMapIdQuery(args.myMapId),
362
+ });
363
+ if (result.length === 0) {
364
+ return { content: [{ type: 'text', text: 'No shared favorite categories.' }] };
365
+ }
366
+ const formatted = result.map(formatFavoriteShare).join('\n');
367
+ return {
368
+ content: [
369
+ {
370
+ type: 'text',
371
+ text: `Shared favorite categories (${result.length}):\n\n${formatted}`,
372
+ },
373
+ ],
374
+ };
375
+ }
376
+ catch (error) {
377
+ return errorResult('listing shared favorite categories', error);
378
+ }
379
+ },
380
+ };
381
+ export const shareMapCategoryTool = {
382
+ name: 'share_map_category',
383
+ title: 'Share Map Category',
384
+ annotations: {
385
+ readOnlyHint: false,
386
+ destructiveHint: false,
387
+ idempotentHint: true,
388
+ openWorldHint: false,
389
+ },
390
+ description: 'Share a favorite category via a public link. The category must already contain at least one favorite.',
391
+ inputSchema: z.object({
392
+ category: z.string().describe('Category name to share'),
393
+ }),
394
+ handler: async (args) => {
395
+ try {
396
+ const result = await fetchMapsAPI(`/favorites-category/${encodeURIComponent(args.category)}/share`, { method: 'POST' });
397
+ return {
398
+ content: [
399
+ {
400
+ type: 'text',
401
+ text: `Category "${args.category}" shared.\n\n${formatFavoriteShare(result)}`,
402
+ },
403
+ ],
404
+ };
405
+ }
406
+ catch (error) {
407
+ return errorResult(`sharing category "${args.category}"`, error);
408
+ }
409
+ },
410
+ };
411
+ export const unshareMapCategoryTool = {
412
+ name: 'unshare_map_category',
413
+ title: 'Unshare Map Category',
226
414
  annotations: {
227
415
  readOnlyHint: false,
228
416
  destructiveHint: true,
229
417
  idempotentHint: true,
230
418
  openWorldHint: false,
231
419
  },
232
- description: 'Delete a map favorite by its ID. This action is irreversible.',
420
+ description: 'Remove the public link share from a favorite category.',
233
421
  inputSchema: z.object({
234
- id: z.number().describe('Favorite ID to delete'),
422
+ category: z.string().describe('Category name to unshare'),
235
423
  }),
236
424
  handler: async (args) => {
237
425
  try {
238
- await fetchMapsExternalAPI(`/favorites/${args.id}`, { method: 'DELETE' });
426
+ const result = await fetchMapsAPI(`/favorites-category/${encodeURIComponent(args.category)}/un-share`, { method: 'POST' });
239
427
  return {
240
- content: [{ type: 'text', text: `Favorite ${args.id} deleted.` }],
428
+ content: [
429
+ {
430
+ type: 'text',
431
+ text: result.did_exist
432
+ ? `Category "${args.category}" unshared.`
433
+ : `Category "${args.category}" was not shared.`,
434
+ },
435
+ ],
241
436
  };
242
437
  }
243
438
  catch (error) {
439
+ return errorResult(`unsharing category "${args.category}"`, error);
440
+ }
441
+ },
442
+ };
443
+ export const addSharedCategoryToMapTool = {
444
+ name: 'add_shared_category_to_map',
445
+ title: 'Add Shared Category To Map',
446
+ annotations: {
447
+ readOnlyHint: false,
448
+ destructiveHint: false,
449
+ idempotentHint: true,
450
+ openWorldHint: false,
451
+ },
452
+ description: 'Add a shared favorite category to a custom map so its favorites show up there.',
453
+ inputSchema: z.object({
454
+ category: z.string().describe('Shared category name'),
455
+ targetMapId: z.number().describe('ID of the custom map to add the shared category to'),
456
+ myMapId: MY_MAP_ID_SCHEMA,
457
+ }),
458
+ handler: async (args) => {
459
+ try {
460
+ const result = await fetchMapsAPI(`/favorites-category/${encodeURIComponent(args.category)}/add-to-map/${args.targetMapId}`, { method: 'PUT', queryParams: myMapIdQuery(args.myMapId) });
244
461
  return {
245
462
  content: [
246
463
  {
247
464
  type: 'text',
248
- text: `Error deleting map favorite ${args.id}: ${error instanceof Error ? error.message : String(error)}`,
465
+ text: `Category "${args.category}" added to map ${args.targetMapId} (${result}).`,
249
466
  },
250
467
  ],
251
- isError: true,
252
468
  };
253
469
  }
470
+ catch (error) {
471
+ return errorResult(`adding category "${args.category}" to map ${args.targetMapId}`, error);
472
+ }
254
473
  },
255
474
  };
256
- // ── Devices Tools (External API) ────────────────────────────────────────────
475
+ // ── Devices Tools ───────────────────────────────────────────────────────────
257
476
  export const listMapDevicesTool = {
258
477
  name: 'list_map_devices',
259
478
  title: 'List Map Devices',
@@ -264,10 +483,14 @@ export const listMapDevicesTool = {
264
483
  openWorldHint: false,
265
484
  },
266
485
  description: 'List GPS tracking devices registered in Nextcloud Maps.',
267
- inputSchema: z.object({}),
268
- handler: async () => {
486
+ inputSchema: z.object({
487
+ myMapId: MY_MAP_ID_SCHEMA,
488
+ }),
489
+ handler: async (args) => {
269
490
  try {
270
- const result = await fetchMapsExternalAPI('/devices');
491
+ const result = await fetchMapsAPI('/devices', {
492
+ queryParams: myMapIdQuery(args.myMapId),
493
+ });
271
494
  if (result.length === 0) {
272
495
  return { content: [{ type: 'text', text: 'No map devices found.' }] };
273
496
  }
@@ -279,15 +502,7 @@ export const listMapDevicesTool = {
279
502
  };
280
503
  }
281
504
  catch (error) {
282
- return {
283
- content: [
284
- {
285
- type: 'text',
286
- text: `Error listing map devices: ${error instanceof Error ? error.message : String(error)}`,
287
- },
288
- ],
289
- isError: true,
290
- };
505
+ return errorResult('listing map devices', error);
291
506
  }
292
507
  },
293
508
  };
@@ -307,13 +522,19 @@ export const getMapDevicePointsTool = {
307
522
  .number()
308
523
  .optional()
309
524
  .describe('Unix timestamp — only return points after this time'),
525
+ limit: z.number().optional().describe('Maximum number of points to return (default 10000)'),
526
+ offset: z.number().optional().describe('Number of points to skip (default 0)'),
310
527
  }),
311
528
  handler: async (args) => {
312
529
  try {
313
530
  const queryParams = {};
314
531
  if (args.pruneBefore !== undefined)
315
532
  queryParams['pruneBefore'] = String(args.pruneBefore);
316
- const result = await fetchMapsExternalAPI(`/devices/${args.id}`, {
533
+ if (args.limit !== undefined)
534
+ queryParams['limit'] = String(args.limit);
535
+ if (args.offset !== undefined)
536
+ queryParams['offset'] = String(args.offset);
537
+ const result = await fetchMapsAPI(`/devices/${args.id}`, {
317
538
  queryParams,
318
539
  });
319
540
  if (result.length === 0) {
@@ -332,15 +553,7 @@ export const getMapDevicePointsTool = {
332
553
  };
333
554
  }
334
555
  catch (error) {
335
- return {
336
- content: [
337
- {
338
- type: 'text',
339
- text: `Error getting device points: ${error instanceof Error ? error.message : String(error)}`,
340
- },
341
- ],
342
- isError: true,
343
- };
556
+ return errorResult('getting device points', error);
344
557
  }
345
558
  },
346
559
  };
@@ -376,7 +589,7 @@ export const addMapDevicePointTool = {
376
589
  body.battery = args.battery;
377
590
  if (args.accuracy !== undefined)
378
591
  body.accuracy = args.accuracy;
379
- const result = await fetchMapsExternalAPI('/devices', {
592
+ const result = await fetchMapsAPI('/devices', {
380
593
  method: 'POST',
381
594
  body,
382
595
  });
@@ -390,15 +603,7 @@ export const addMapDevicePointTool = {
390
603
  };
391
604
  }
392
605
  catch (error) {
393
- return {
394
- content: [
395
- {
396
- type: 'text',
397
- text: `Error adding device point: ${error instanceof Error ? error.message : String(error)}`,
398
- },
399
- ],
400
- isError: true,
401
- };
606
+ return errorResult('adding device point', error);
402
607
  }
403
608
  },
404
609
  };
@@ -411,16 +616,19 @@ export const updateMapDeviceTool = {
411
616
  idempotentHint: true,
412
617
  openWorldHint: false,
413
618
  },
414
- description: "Update a device's display color.",
619
+ description: "Update a device's display color and/or name. At least one must be provided.",
415
620
  inputSchema: z.object({
416
621
  id: z.number().describe('Device ID'),
417
- color: z.string().describe("New color (e.g. '#ff0000')"),
622
+ color: z.string().optional().describe("New color (e.g. '#ff0000')"),
623
+ name: z.string().optional().describe('New device name (user agent)'),
418
624
  }),
419
625
  handler: async (args) => {
420
626
  try {
421
- const result = await fetchMapsExternalAPI(`/devices/${args.id}`, {
627
+ // The controller types both as non-nullable strings and ignores empty ones,
628
+ // so always send both and let it skip whatever the caller left out.
629
+ const result = await fetchMapsAPI(`/devices/${args.id}`, {
422
630
  method: 'PUT',
423
- body: { color: args.color },
631
+ body: { color: args.color ?? '', name: args.name ?? '' },
424
632
  });
425
633
  return {
426
634
  content: [
@@ -429,52 +637,166 @@ export const updateMapDeviceTool = {
429
637
  };
430
638
  }
431
639
  catch (error) {
640
+ return errorResult(`updating device ${args.id}`, error);
641
+ }
642
+ },
643
+ };
644
+ export const deleteMapDeviceTool = {
645
+ name: 'delete_map_device',
646
+ title: 'Delete Map Device',
647
+ annotations: {
648
+ readOnlyHint: false,
649
+ destructiveHint: true,
650
+ idempotentHint: true,
651
+ openWorldHint: false,
652
+ },
653
+ description: 'Delete a GPS tracking device and all its location points. This action is irreversible.',
654
+ inputSchema: z.object({
655
+ id: z.number().describe('Device ID to delete'),
656
+ }),
657
+ handler: async (args) => {
658
+ try {
659
+ await fetchMapsAPI(`/devices/${args.id}`, { method: 'DELETE' });
660
+ return {
661
+ content: [{ type: 'text', text: `Device ${args.id} deleted.` }],
662
+ };
663
+ }
664
+ catch (error) {
665
+ return errorResult(`deleting device ${args.id}`, error);
666
+ }
667
+ },
668
+ };
669
+ // ── Device Sharing Tools ────────────────────────────────────────────────────
670
+ export const shareMapDeviceTool = {
671
+ name: 'share_map_device',
672
+ title: 'Share Map Device',
673
+ annotations: {
674
+ readOnlyHint: false,
675
+ destructiveHint: false,
676
+ idempotentHint: false,
677
+ openWorldHint: false,
678
+ },
679
+ description: 'Share a GPS device via a public link, limited to a time window. Returns the share token.',
680
+ inputSchema: z.object({
681
+ id: z.number().describe('Device ID to share'),
682
+ timestampFrom: z.number().describe('Unix timestamp — start of the shared time window'),
683
+ timestampTo: z.number().describe('Unix timestamp — end of the shared time window'),
684
+ }),
685
+ handler: async (args) => {
686
+ try {
687
+ const result = await fetchMapsAPI(`/devices/${args.id}/share`, {
688
+ method: 'POST',
689
+ body: { timestampFrom: args.timestampFrom, timestampTo: args.timestampTo },
690
+ });
432
691
  return {
433
692
  content: [
434
693
  {
435
694
  type: 'text',
436
- text: `Error updating device ${args.id}: ${error instanceof Error ? error.message : String(error)}`,
695
+ text: `Device ${args.id} shared.\n\n${formatDeviceShare(result)}`,
437
696
  },
438
697
  ],
439
- isError: true,
440
698
  };
441
699
  }
700
+ catch (error) {
701
+ return errorResult(`sharing device ${args.id}`, error);
702
+ }
442
703
  },
443
704
  };
444
- export const deleteMapDeviceTool = {
445
- name: 'delete_map_device',
446
- title: 'Delete Map Device',
705
+ export const listSharedMapDevicesTool = {
706
+ name: 'list_shared_map_devices',
707
+ title: 'List Shared Map Devices',
708
+ annotations: {
709
+ readOnlyHint: true,
710
+ destructiveHint: false,
711
+ idempotentHint: true,
712
+ openWorldHint: false,
713
+ },
714
+ description: 'List device shares that have been added to a custom map. Requires myMapId — the default map holds no device shares.',
715
+ inputSchema: z.object({
716
+ myMapId: z.number().describe('Custom map ID to list device shares from'),
717
+ }),
718
+ handler: async (args) => {
719
+ try {
720
+ const result = await fetchMapsAPI('/devices/s/', {
721
+ queryParams: { myMapId: String(args.myMapId) },
722
+ });
723
+ if (result.length === 0) {
724
+ return {
725
+ content: [{ type: 'text', text: `No shared devices on map ${args.myMapId}.` }],
726
+ };
727
+ }
728
+ const formatted = result.map(formatDeviceShare).join('\n');
729
+ return {
730
+ content: [
731
+ {
732
+ type: 'text',
733
+ text: `Shared devices on map ${args.myMapId} (${result.length}):\n\n${formatted}`,
734
+ },
735
+ ],
736
+ };
737
+ }
738
+ catch (error) {
739
+ return errorResult('listing shared devices', error);
740
+ }
741
+ },
742
+ };
743
+ export const removeMapDeviceShareTool = {
744
+ name: 'remove_map_device_share',
745
+ title: 'Remove Map Device Share',
447
746
  annotations: {
448
747
  readOnlyHint: false,
449
748
  destructiveHint: true,
450
749
  idempotentHint: true,
451
750
  openWorldHint: false,
452
751
  },
453
- description: 'Delete a GPS tracking device and all its location points. This action is irreversible.',
752
+ description: 'Revoke a device share by its token.',
454
753
  inputSchema: z.object({
455
- id: z.number().describe('Device ID to delete'),
754
+ token: z.string().describe('Share token to revoke'),
456
755
  }),
457
756
  handler: async (args) => {
458
757
  try {
459
- await fetchMapsExternalAPI(`/devices/${args.id}`, { method: 'DELETE' });
758
+ await fetchMapsAPI(`/devices/s/${encodeURIComponent(args.token)}`, { method: 'DELETE' });
460
759
  return {
461
- content: [{ type: 'text', text: `Device ${args.id} deleted.` }],
760
+ content: [{ type: 'text', text: `Device share ${args.token} removed.` }],
462
761
  };
463
762
  }
464
763
  catch (error) {
764
+ return errorResult(`removing device share ${args.token}`, error);
765
+ }
766
+ },
767
+ };
768
+ export const addSharedDeviceToMapTool = {
769
+ name: 'add_shared_device_to_map',
770
+ title: 'Add Shared Device To Map',
771
+ annotations: {
772
+ readOnlyHint: false,
773
+ destructiveHint: false,
774
+ idempotentHint: true,
775
+ openWorldHint: false,
776
+ },
777
+ description: 'Add a shared device (by token) to a custom map so its track shows up there.',
778
+ inputSchema: z.object({
779
+ token: z.string().describe('Device share token'),
780
+ targetMapId: z.number().describe('ID of the custom map to add the shared device to'),
781
+ }),
782
+ handler: async (args) => {
783
+ try {
784
+ const result = await fetchMapsAPI(`/devices/s/${encodeURIComponent(args.token)}/map-link/${args.targetMapId}`, { method: 'POST' });
465
785
  return {
466
786
  content: [
467
787
  {
468
788
  type: 'text',
469
- text: `Error deleting device ${args.id}: ${error instanceof Error ? error.message : String(error)}`,
789
+ text: `Shared device added to map ${args.targetMapId} (${result}).`,
470
790
  },
471
791
  ],
472
- isError: true,
473
792
  };
474
793
  }
794
+ catch (error) {
795
+ return errorResult(`adding shared device to map ${args.targetMapId}`, error);
796
+ }
475
797
  },
476
798
  };
477
- // ── Tracks Tools (Internal API) ─────────────────────────────────────────────
799
+ // ── Tracks Tools ─────────────────────────────────────────────
478
800
  export const listMapTracksTool = {
479
801
  name: 'list_map_tracks',
480
802
  title: 'List Map Tracks',
@@ -601,7 +923,7 @@ export const updateMapTrackTool = {
601
923
  }
602
924
  },
603
925
  };
604
- // ── Photos Tools (Internal API) ─────────────────────────────────────────────
926
+ // ── Photos Tools ─────────────────────────────────────────────
605
927
  export const listMapPhotosTool = {
606
928
  name: 'list_map_photos',
607
929
  title: 'List Map Photos',
@@ -787,7 +1109,236 @@ export const resetMapPhotoCoordsTool = {
787
1109
  }
788
1110
  },
789
1111
  };
790
- // ── My Maps Tools (Internal API) ────────────────────────────────────────────
1112
+ export const getMapPhotoJobStatusTool = {
1113
+ name: 'get_map_photo_job_status',
1114
+ title: 'Get Map Photo Job Status',
1115
+ annotations: {
1116
+ readOnlyHint: true,
1117
+ destructiveHint: false,
1118
+ idempotentHint: true,
1119
+ openWorldHint: false,
1120
+ },
1121
+ description: 'Get the status of the background job that scans photos for GPS coordinates. Useful after uploading photos to see whether geolocation has finished.',
1122
+ inputSchema: z.object({}),
1123
+ handler: async () => {
1124
+ try {
1125
+ const result = await fetchMapsAPI('/photos/backgroundJobStatus');
1126
+ const lines = Object.entries(result).map(([k, v]) => `- ${k}: ${JSON.stringify(v)}`);
1127
+ return {
1128
+ content: [
1129
+ {
1130
+ type: 'text',
1131
+ text: lines.length > 0
1132
+ ? `Photo background job status:\n\n${lines.join('\n')}`
1133
+ : 'No photo background job status reported.',
1134
+ },
1135
+ ],
1136
+ };
1137
+ }
1138
+ catch (error) {
1139
+ return errorResult('getting photo background job status', error);
1140
+ }
1141
+ },
1142
+ };
1143
+ // ── Contacts Tools ──────────────────────────────────────────────────────────
1144
+ export const listMapContactsTool = {
1145
+ name: 'list_map_contacts',
1146
+ title: 'List Map Contacts',
1147
+ annotations: {
1148
+ readOnlyHint: true,
1149
+ destructiveHint: false,
1150
+ idempotentHint: true,
1151
+ openWorldHint: false,
1152
+ },
1153
+ description: 'List address book contacts that carry a geographic address, as shown on the map.',
1154
+ inputSchema: z.object({
1155
+ myMapId: MY_MAP_ID_SCHEMA,
1156
+ }),
1157
+ handler: async (args) => {
1158
+ try {
1159
+ const result = await fetchMapsAPI('/contacts', {
1160
+ queryParams: myMapIdQuery(args.myMapId),
1161
+ });
1162
+ if (result.length === 0) {
1163
+ return { content: [{ type: 'text', text: 'No contacts with addresses found.' }] };
1164
+ }
1165
+ const formatted = result.map(formatContact).join('\n');
1166
+ return {
1167
+ content: [
1168
+ { type: 'text', text: `Map contacts (${result.length}):\n\n${formatted}` },
1169
+ ],
1170
+ };
1171
+ }
1172
+ catch (error) {
1173
+ return errorResult('listing map contacts', error);
1174
+ }
1175
+ },
1176
+ };
1177
+ export const searchMapContactsTool = {
1178
+ name: 'search_map_contacts',
1179
+ title: 'Search Map Contacts',
1180
+ annotations: {
1181
+ readOnlyHint: true,
1182
+ destructiveHint: false,
1183
+ idempotentHint: true,
1184
+ openWorldHint: false,
1185
+ },
1186
+ description: 'Search address book contacts by name, to find the bookid/uri/uid needed to place a contact on the map.',
1187
+ inputSchema: z.object({
1188
+ query: z.string().describe('Search term matched against contact display names'),
1189
+ }),
1190
+ handler: async (args) => {
1191
+ try {
1192
+ const result = await fetchMapsAPI('/contacts-search', {
1193
+ queryParams: { query: args.query },
1194
+ });
1195
+ if (result.length === 0) {
1196
+ return {
1197
+ content: [{ type: 'text', text: `No contacts matching "${args.query}".` }],
1198
+ };
1199
+ }
1200
+ const formatted = result
1201
+ .map((c) => `- **${c.FN}** — UID: ${c.UID} | Book ID: ${c.BOOKID} | URI: ${c.URI}`)
1202
+ .join('\n');
1203
+ return {
1204
+ content: [
1205
+ {
1206
+ type: 'text',
1207
+ text: `Contacts matching "${args.query}" (${result.length}):\n\n${formatted}`,
1208
+ },
1209
+ ],
1210
+ };
1211
+ }
1212
+ catch (error) {
1213
+ return errorResult(`searching contacts for "${args.query}"`, error);
1214
+ }
1215
+ },
1216
+ };
1217
+ export const placeMapContactTool = {
1218
+ name: 'place_map_contact',
1219
+ title: 'Place Map Contact',
1220
+ annotations: {
1221
+ readOnlyHint: false,
1222
+ destructiveHint: true,
1223
+ idempotentHint: true,
1224
+ openWorldHint: false,
1225
+ },
1226
+ description: "Set a contact's geographic address and coordinates so it appears on the map. Use search_map_contacts to look up bookid, uri and uid.",
1227
+ inputSchema: z.object({
1228
+ bookid: z.string().describe('Address book ID'),
1229
+ uri: z.string().describe('Contact URI (e.g. "abc123.vcf")'),
1230
+ uid: z.string().describe('Contact UID'),
1231
+ lat: z.number().optional().describe('Latitude'),
1232
+ lng: z.number().optional().describe('Longitude'),
1233
+ address_string: z
1234
+ .string()
1235
+ .optional()
1236
+ .describe('Full address as a single string, used instead of the individual fields'),
1237
+ attraction: z.string().optional().describe('Point of interest name'),
1238
+ house_number: z.string().optional().describe('House number'),
1239
+ road: z.string().optional().describe('Street'),
1240
+ postcode: z.string().optional().describe('Postal code'),
1241
+ city: z.string().optional().describe('City'),
1242
+ state: z.string().optional().describe('State or region'),
1243
+ country: z.string().optional().describe('Country'),
1244
+ type: z.string().optional().describe("Address type (e.g. 'HOME', 'WORK')"),
1245
+ myMapId: MY_MAP_ID_SCHEMA,
1246
+ }),
1247
+ handler: async (args) => {
1248
+ try {
1249
+ const { bookid, uri, ...rest } = args;
1250
+ const body = {};
1251
+ for (const [key, value] of Object.entries(rest)) {
1252
+ if (value !== undefined)
1253
+ body[key] = value;
1254
+ }
1255
+ const result = await fetchMapsAPI(`/contacts/${encodeURIComponent(bookid)}/${encodeURIComponent(uri)}`, { method: 'PUT', body });
1256
+ if (result !== 'EDITED') {
1257
+ return errorResult(`placing contact ${args.uid}`, new Error(result));
1258
+ }
1259
+ return {
1260
+ content: [
1261
+ {
1262
+ type: 'text',
1263
+ text: `Contact ${args.uid} placed${args.lat !== undefined && args.lng !== undefined ? ` at ${args.lat}, ${args.lng}` : ''}.`,
1264
+ },
1265
+ ],
1266
+ };
1267
+ }
1268
+ catch (error) {
1269
+ return errorResult(`placing contact ${args.uid}`, error);
1270
+ }
1271
+ },
1272
+ };
1273
+ export const addContactToMapTool = {
1274
+ name: 'add_contact_to_map',
1275
+ title: 'Add Contact To Map',
1276
+ annotations: {
1277
+ readOnlyHint: false,
1278
+ destructiveHint: false,
1279
+ idempotentHint: true,
1280
+ openWorldHint: false,
1281
+ },
1282
+ description: 'Copy a contact into a custom map so it shows up as a pin on that map.',
1283
+ inputSchema: z.object({
1284
+ bookid: z.string().describe('Address book ID'),
1285
+ uri: z.string().describe('Contact URI (e.g. "abc123.vcf")'),
1286
+ myMapId: z.number().describe('ID of the custom map to add the contact to'),
1287
+ }),
1288
+ handler: async (args) => {
1289
+ try {
1290
+ const result = await fetchMapsAPI(`/contacts/${encodeURIComponent(args.bookid)}/${encodeURIComponent(args.uri)}/add-to-map/`, { method: 'PUT', body: { myMapId: args.myMapId } });
1291
+ if (result !== 'DONE') {
1292
+ return errorResult(`adding contact to map ${args.myMapId}`, new Error(result));
1293
+ }
1294
+ return {
1295
+ content: [
1296
+ { type: 'text', text: `Contact ${args.uri} added to map ${args.myMapId}.` },
1297
+ ],
1298
+ };
1299
+ }
1300
+ catch (error) {
1301
+ return errorResult(`adding contact to map ${args.myMapId}`, error);
1302
+ }
1303
+ },
1304
+ };
1305
+ export const deleteMapContactAddressTool = {
1306
+ name: 'delete_map_contact_address',
1307
+ title: 'Delete Map Contact Address',
1308
+ annotations: {
1309
+ readOnlyHint: false,
1310
+ destructiveHint: true,
1311
+ idempotentHint: true,
1312
+ openWorldHint: false,
1313
+ },
1314
+ description: 'Remove an address and its coordinates from a contact, taking it off the map. The contact itself is kept. Use list_map_contacts to get the exact ADR and GEO values.',
1315
+ inputSchema: z.object({
1316
+ bookid: z.string().describe('Address book ID'),
1317
+ uri: z.string().describe('Contact URI (e.g. "abc123.vcf")'),
1318
+ uid: z.string().describe('Contact UID'),
1319
+ adr: z.string().describe('The ADR value to remove, exactly as returned by list_map_contacts'),
1320
+ geo: z.string().describe('The GEO value to remove, exactly as returned by list_map_contacts'),
1321
+ myMapId: MY_MAP_ID_SCHEMA,
1322
+ }),
1323
+ handler: async (args) => {
1324
+ try {
1325
+ const body = { uid: args.uid, adr: args.adr, geo: args.geo };
1326
+ if (args.myMapId !== undefined)
1327
+ body.myMapId = args.myMapId;
1328
+ const result = await fetchMapsAPI(`/contacts/${encodeURIComponent(args.bookid)}/${encodeURIComponent(args.uri)}`, { method: 'DELETE', body });
1329
+ if (result !== 'DELETED') {
1330
+ return errorResult(`deleting address of contact ${args.uid}`, new Error(result));
1331
+ }
1332
+ return {
1333
+ content: [{ type: 'text', text: `Address removed from contact ${args.uid}.` }],
1334
+ };
1335
+ }
1336
+ catch (error) {
1337
+ return errorResult(`deleting address of contact ${args.uid}`, error);
1338
+ }
1339
+ },
1340
+ };
1341
+ // ── My Maps Tools ────────────────────────────────────────────
791
1342
  export const listMapsTool = {
792
1343
  name: 'list_maps',
793
1344
  title: 'List Maps',
@@ -938,7 +1489,7 @@ export const deleteMapTool = {
938
1489
  }
939
1490
  },
940
1491
  };
941
- // ── Routing Tool (Internal API) ─────────────────────────────────────────────
1492
+ // ── Routing Tool ─────────────────────────────────────────────
942
1493
  export const exportMapRouteTool = {
943
1494
  name: 'export_map_route',
944
1495
  title: 'Export Map Route',
@@ -1003,7 +1554,7 @@ export const exportMapRouteTool = {
1003
1554
  }
1004
1555
  },
1005
1556
  };
1006
- // ── Import/Export Tools (Internal API) ───────────────────────────────────────
1557
+ // ── Import/Export Tools ───────────────────────────────────────
1007
1558
  export const exportMapFavoritesTool = {
1008
1559
  name: 'export_map_favorites',
1009
1560
  title: 'Export Map Favorites',
@@ -1188,12 +1739,23 @@ export const mapsTools = [
1188
1739
  createMapFavoriteTool,
1189
1740
  updateMapFavoriteTool,
1190
1741
  deleteMapFavoriteTool,
1742
+ renameMapFavoriteCategoryTool,
1743
+ // Favorite category sharing
1744
+ listSharedMapCategoriesTool,
1745
+ shareMapCategoryTool,
1746
+ unshareMapCategoryTool,
1747
+ addSharedCategoryToMapTool,
1191
1748
  // Devices
1192
1749
  listMapDevicesTool,
1193
1750
  getMapDevicePointsTool,
1194
1751
  addMapDevicePointTool,
1195
1752
  updateMapDeviceTool,
1196
1753
  deleteMapDeviceTool,
1754
+ // Device sharing
1755
+ shareMapDeviceTool,
1756
+ listSharedMapDevicesTool,
1757
+ removeMapDeviceShareTool,
1758
+ addSharedDeviceToMapTool,
1197
1759
  // Tracks
1198
1760
  listMapTracksTool,
1199
1761
  getMapTrackTool,
@@ -1203,6 +1765,13 @@ export const mapsTools = [
1203
1765
  listMapPhotosNonlocalizedTool,
1204
1766
  placeMapPhotosTool,
1205
1767
  resetMapPhotoCoordsTool,
1768
+ getMapPhotoJobStatusTool,
1769
+ // Contacts
1770
+ listMapContactsTool,
1771
+ searchMapContactsTool,
1772
+ placeMapContactTool,
1773
+ addContactToMapTool,
1774
+ deleteMapContactAddressTool,
1206
1775
  // My Maps
1207
1776
  listMapsTool,
1208
1777
  createMapTool,
@@ -9,6 +9,7 @@ import { createServer, SERVER_VERSION } from '../server.js';
9
9
  import { NextcloudOAuthProvider } from '../auth/provider.js';
10
10
  import { probeStateDir, StateDirNotWritableError, stateUnwritableMessage, markStateUnwritableWarned, } from '../auth/store.js';
11
11
  import { loginHandler } from '../auth/login.js';
12
+ import { LOGIN_STYLESHEET, LOGIN_STYLESHEET_PATH } from '../auth/login-page-css.js';
12
13
  import { isPublicRequest } from './lazy-auth.js';
13
14
  import { logger } from '../logger.js';
14
15
  import { fetchStatus } from '../client/ocs.js';
@@ -133,7 +134,70 @@ export async function startHttp() {
133
134
  allowedHosts = [...new Set(['localhost', '127.0.0.1', ...extras])];
134
135
  }
135
136
  }
137
+ // Explicit CORS. Browser-based MCP clients need these headers; previously the
138
+ // server sent none and relied entirely on the reverse proxy. The issuer origin
139
+ // is trusted by default (it serves the login page), and MCP_CORS_ORIGINS adds
140
+ // extra origins — comma-separated, whitespace-trimmed, same idiom as
141
+ // MCP_ALLOWED_HOSTS. Origins are matched exactly and echoed back; the wildcard
142
+ // '*' is never sent, since these endpoints are credentialed.
143
+ const allowedOrigins = new Set();
144
+ const issuerForCors = process.env.MCP_AUTH_ISSUER;
145
+ if (authEnabled && issuerForCors) {
146
+ try {
147
+ allowedOrigins.add(new URL(issuerForCors).origin);
148
+ }
149
+ catch {
150
+ // Malformed issuer URL — the validation below will throw a clear error.
151
+ }
152
+ }
153
+ const extraOrigins = process.env.MCP_CORS_ORIGINS;
154
+ if (extraOrigins) {
155
+ for (const raw of extraOrigins.split(',')) {
156
+ const trimmed = raw.trim();
157
+ if (!trimmed)
158
+ continue;
159
+ try {
160
+ allowedOrigins.add(new URL(trimmed).origin);
161
+ }
162
+ catch {
163
+ logger.warn({ origin: trimmed }, '[startup] Ignoring malformed MCP_CORS_ORIGINS entry');
164
+ }
165
+ }
166
+ }
136
167
  const app = createMcpExpressApp({ host, allowedHosts });
168
+ // Mounted ahead of every other route so that preflights are answered before
169
+ // the auth chain runs — an OPTIONS request carries no Authorization header
170
+ // and would otherwise be rejected with 401 by requireBearerAuth.
171
+ app.use((req, res, next) => {
172
+ const origin = req.headers?.origin;
173
+ if (typeof origin === 'string' && allowedOrigins.has(origin)) {
174
+ res.setHeader('Access-Control-Allow-Origin', origin);
175
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
176
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID');
177
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, WWW-Authenticate');
178
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
179
+ res.setHeader('Access-Control-Max-Age', '86400');
180
+ }
181
+ // Always vary on Origin: the response differs per origin even when no
182
+ // headers are added, so a shared cache must not reuse one for another.
183
+ res.setHeader('Vary', 'Origin');
184
+ if (req.method === 'OPTIONS') {
185
+ res.status(204).end();
186
+ return;
187
+ }
188
+ next();
189
+ });
190
+ // Stylesheet for the OAuth login page. Served as its own document so the page
191
+ // needs no inline <style>, which lets its CSP drop 'unsafe-inline'. Must stay
192
+ // ahead of the auth middleware — the login page is shown to anonymous users.
193
+ app.get(LOGIN_STYLESHEET_PATH, (_req, res) => {
194
+ res
195
+ .set('Content-Type', 'text/css; charset=utf-8')
196
+ .set('Cache-Control', 'public, max-age=86400')
197
+ .set('X-Content-Type-Options', 'nosniff')
198
+ .status(200)
199
+ .send(LOGIN_STYLESHEET);
200
+ });
137
201
  // Simple health check — bypasses all auth middleware so Docker health checks
138
202
  // work even before OAuth is fully configured or TLS is verified.
139
203
  app.get('/health', (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",