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 +2 -0
- package/dist/auth/login-page-css.js +23 -0
- package/dist/auth/login.js +6 -6
- package/dist/auth/provider.js +19 -18
- package/dist/client/maps.js +0 -24
- package/dist/tools/apps/maps.js +671 -102
- package/dist/transports/http.js +64 -0
- package/package.json +1 -1
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';
|
package/dist/auth/login.js
CHANGED
|
@@ -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({
|
package/dist/auth/provider.js
CHANGED
|
@@ -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, '"')
|
|
38
39
|
.replace(/'/g, ''');
|
|
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
|
-
<
|
|
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({
|
package/dist/client/maps.js
CHANGED
|
@@ -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();
|
package/dist/tools/apps/maps.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
50
|
+
if (p.altitude != null)
|
|
27
51
|
parts.push(`alt: ${p.altitude}m`);
|
|
28
|
-
if (p.accuracy
|
|
52
|
+
if (p.accuracy != null)
|
|
29
53
|
parts.push(`acc: ${p.accuracy}m`);
|
|
30
|
-
if (p.battery
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: `
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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: '
|
|
420
|
+
description: 'Remove the public link share from a favorite category.',
|
|
233
421
|
inputSchema: z.object({
|
|
234
|
-
|
|
422
|
+
category: z.string().describe('Category name to unshare'),
|
|
235
423
|
}),
|
|
236
424
|
handler: async (args) => {
|
|
237
425
|
try {
|
|
238
|
-
await
|
|
426
|
+
const result = await fetchMapsAPI(`/favorites-category/${encodeURIComponent(args.category)}/un-share`, { method: 'POST' });
|
|
239
427
|
return {
|
|
240
|
-
content: [
|
|
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: `
|
|
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
|
|
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
|
-
|
|
486
|
+
inputSchema: z.object({
|
|
487
|
+
myMapId: MY_MAP_ID_SCHEMA,
|
|
488
|
+
}),
|
|
489
|
+
handler: async (args) => {
|
|
269
490
|
try {
|
|
270
|
-
const result = await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: `
|
|
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
|
|
445
|
-
name: '
|
|
446
|
-
title: '
|
|
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: '
|
|
752
|
+
description: 'Revoke a device share by its token.',
|
|
454
753
|
inputSchema: z.object({
|
|
455
|
-
|
|
754
|
+
token: z.string().describe('Share token to revoke'),
|
|
456
755
|
}),
|
|
457
756
|
handler: async (args) => {
|
|
458
757
|
try {
|
|
459
|
-
await
|
|
758
|
+
await fetchMapsAPI(`/devices/s/${encodeURIComponent(args.token)}`, { method: 'DELETE' });
|
|
460
759
|
return {
|
|
461
|
-
content: [{ type: 'text', text: `Device ${args.
|
|
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: `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
package/dist/transports/http.js
CHANGED
|
@@ -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) => {
|