aiquila-mcp 0.3.29 → 0.3.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/provider.js +5 -2
- package/dist/client/caldav.js +10 -2
- package/dist/tools/apps/contacts.js +77 -119
- package/dist/tools/dav-utils.js +30 -8
- package/dist/tools/vcard.js +242 -0
- package/dist/transports/http.js +51 -3
- package/dist/transports/lazy-auth.js +63 -0
- package/package.json +1 -1
package/dist/auth/provider.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { SignJWT, jwtVerify } from 'jose';
|
|
4
|
-
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
4
|
+
import { InvalidGrantError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
5
5
|
import { ClientsStore, CodeStore, RefreshStore } from './store.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
// --- JWT helpers (HMAC-SHA256 via jose) ---
|
|
@@ -196,7 +196,10 @@ export class NextcloudOAuthProvider {
|
|
|
196
196
|
const claims = await verifyJwt(token, this.getSecret(), issuer ? { issuer, audience: new URL('/mcp', issuer).toString() } : undefined);
|
|
197
197
|
if (!claims) {
|
|
198
198
|
logger.warn('[auth] Access token verification failed: invalid or expired token');
|
|
199
|
-
|
|
199
|
+
// InvalidTokenError (not a bare Error) so the SDK answers 401 with a
|
|
200
|
+
// WWW-Authenticate challenge. A bare Error becomes a 500, and clients
|
|
201
|
+
// treat that as a server fault rather than a cue to re-authenticate.
|
|
202
|
+
throw new InvalidTokenError('Invalid or expired access token');
|
|
200
203
|
}
|
|
201
204
|
return {
|
|
202
205
|
token,
|
package/dist/client/caldav.js
CHANGED
|
@@ -12,12 +12,20 @@ export function nsTagContent(localName) {
|
|
|
12
12
|
* Needed because we parse XML responses with regex instead of a DOM parser.
|
|
13
13
|
*/
|
|
14
14
|
export function decodeXmlEntities(text) {
|
|
15
|
-
return text
|
|
15
|
+
return (text
|
|
16
|
+
// Numeric character references. SabreDAV serializes calendar-data and
|
|
17
|
+
// address-data through libxml, which escapes carriage returns as ` `
|
|
18
|
+
// so they survive XML whitespace normalization. Leaving those undecoded
|
|
19
|
+
// corrupts every line ending of the iCalendar/vCard payload (GH #396).
|
|
20
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
21
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
16
22
|
.replace(/"/g, '"')
|
|
17
23
|
.replace(/'/g, "'")
|
|
18
24
|
.replace(/</g, '<')
|
|
19
25
|
.replace(/>/g, '>')
|
|
20
|
-
|
|
26
|
+
// `&` last, so an encoded `&#13;` decodes to the literal text
|
|
27
|
+
// ` ` rather than being decoded twice into a carriage return.
|
|
28
|
+
.replace(/&/g, '&'));
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Fetch data from CalDAV endpoint using basic authentication
|
|
@@ -1,44 +1,38 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { decodeXmlEntities, fetchCalDAV, nsTagContent } from '../../client/caldav.js';
|
|
4
|
-
import { escapeVCardValue, unescapeVCardValue } from '../dav-utils.js';
|
|
4
|
+
import { escapeVCardValue, sanitizeVCardUriValue, unescapeVCardValue } from '../dav-utils.js';
|
|
5
|
+
import { getParamValues, getProperty, parseVCard, property, replaceAll, serializeVCard, setProperty, } from '../vcard.js';
|
|
5
6
|
import { getNextcloudConfig } from '../types.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// vCard helpers
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
|
-
function unfoldVCardLines(text) {
|
|
10
|
-
return text.replace(/\r?\n[ \t]/g, '');
|
|
11
|
-
}
|
|
12
10
|
/**
|
|
13
|
-
* Extract TYPE parameter
|
|
14
|
-
*
|
|
11
|
+
* Extract the TYPE parameter of a property, e.g. `WORK` from `TEL;TYPE=WORK:`.
|
|
12
|
+
* vCard 4.0 may repeat the parameter (`TYPE=voice;TYPE=cell`) or quote a list
|
|
13
|
+
* (`TYPE="voice,cell"`); both collapse to a single comma-joined string.
|
|
15
14
|
*/
|
|
16
|
-
function extractType(
|
|
17
|
-
const
|
|
18
|
-
return
|
|
15
|
+
function extractType(prop) {
|
|
16
|
+
const values = getParamValues(prop, 'TYPE');
|
|
17
|
+
return values.length > 0 ? values.join(',').toUpperCase() : undefined;
|
|
19
18
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* - property exists -> replace its value
|
|
24
|
-
* - property absent -> insert before END:VCARD
|
|
25
|
-
*/
|
|
26
|
-
function setVCardProperty(vcardData, propName, value) {
|
|
27
|
-
const regex = new RegExp(`^${propName}(;[^:]*)?:.*$`, 'mi');
|
|
28
|
-
if (value === null) {
|
|
29
|
-
return vcardData.replace(regex, '').replace(/(\r?\n){2,}/g, '\r\n');
|
|
30
|
-
}
|
|
31
|
-
if (regex.test(vcardData)) {
|
|
32
|
-
return vcardData.replace(regex, `${propName}:${value}`);
|
|
33
|
-
}
|
|
34
|
-
return vcardData.replace(/END:VCARD/i, `${propName}:${value}\r\nEND:VCARD`);
|
|
19
|
+
/** Build a property with an optional TYPE parameter. */
|
|
20
|
+
function typedProperty(name, value, type) {
|
|
21
|
+
return property(name, value, type ? [['TYPE', type]] : []);
|
|
35
22
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
/** Serialize an ADR value from its structured parts, escaping each component. */
|
|
24
|
+
function buildAdrValue(addr) {
|
|
25
|
+
const part = (v) => (v ? escapeVCardValue(v) : '');
|
|
26
|
+
// ADR:po-box;extended;street;locality;region;postal-code;country
|
|
27
|
+
return [
|
|
28
|
+
'',
|
|
29
|
+
'',
|
|
30
|
+
part(addr.street),
|
|
31
|
+
part(addr.city),
|
|
32
|
+
part(addr.region),
|
|
33
|
+
part(addr.postalCode),
|
|
34
|
+
part(addr.country),
|
|
35
|
+
].join(';');
|
|
42
36
|
}
|
|
43
37
|
// ---------------------------------------------------------------------------
|
|
44
38
|
// Parsing
|
|
@@ -72,8 +66,7 @@ function parseAddressBooks(responseXml) {
|
|
|
72
66
|
* Parse a single vCard text block into a ParsedContact.
|
|
73
67
|
*/
|
|
74
68
|
function parseVCardBlock(vcardText) {
|
|
75
|
-
const
|
|
76
|
-
const lines = unfolded.split(/\r?\n/);
|
|
69
|
+
const doc = parseVCard(vcardText);
|
|
77
70
|
const contact = {
|
|
78
71
|
uid: '',
|
|
79
72
|
fullName: '',
|
|
@@ -82,14 +75,8 @@ function parseVCardBlock(vcardText) {
|
|
|
82
75
|
addresses: [],
|
|
83
76
|
categories: [],
|
|
84
77
|
};
|
|
85
|
-
for (const
|
|
86
|
-
const
|
|
87
|
-
if (!propMatch)
|
|
88
|
-
continue;
|
|
89
|
-
const [, rawName, params, rawValue] = propMatch;
|
|
90
|
-
const name = rawName.toUpperCase();
|
|
91
|
-
const value = rawValue || '';
|
|
92
|
-
const paramStr = params || '';
|
|
78
|
+
for (const prop of doc.properties) {
|
|
79
|
+
const { name, value } = prop;
|
|
93
80
|
switch (name) {
|
|
94
81
|
case 'UID':
|
|
95
82
|
contact.uid = value;
|
|
@@ -111,13 +98,13 @@ function parseVCardBlock(vcardText) {
|
|
|
111
98
|
case 'EMAIL':
|
|
112
99
|
contact.emails.push({
|
|
113
100
|
value: unescapeVCardValue(value),
|
|
114
|
-
type: extractType(
|
|
101
|
+
type: extractType(prop),
|
|
115
102
|
});
|
|
116
103
|
break;
|
|
117
104
|
case 'TEL':
|
|
118
105
|
contact.phones.push({
|
|
119
106
|
value: unescapeVCardValue(value),
|
|
120
|
-
type: extractType(
|
|
107
|
+
type: extractType(prop),
|
|
121
108
|
});
|
|
122
109
|
break;
|
|
123
110
|
case 'ADR': {
|
|
@@ -129,7 +116,7 @@ function parseVCardBlock(vcardText) {
|
|
|
129
116
|
region: unescapeVCardValue(adrParts[4] || ''),
|
|
130
117
|
postalCode: unescapeVCardValue(adrParts[5] || ''),
|
|
131
118
|
country: unescapeVCardValue(adrParts[6] || ''),
|
|
132
|
-
type: extractType(
|
|
119
|
+
type: extractType(prop),
|
|
133
120
|
});
|
|
134
121
|
break;
|
|
135
122
|
}
|
|
@@ -178,7 +165,7 @@ function parseVCards(responseXml) {
|
|
|
178
165
|
const cardDataMatch = block.match(/<(?:[a-z0-9]+:)?address-data[^>]*>([\s\S]*?)<\/(?:[a-z0-9]+:)?address-data>/);
|
|
179
166
|
if (!cardDataMatch)
|
|
180
167
|
continue;
|
|
181
|
-
const vcardText = cardDataMatch[1];
|
|
168
|
+
const vcardText = decodeXmlEntities(cardDataMatch[1]);
|
|
182
169
|
const vcardBlocks = vcardText.match(/BEGIN:VCARD[\s\S]*?END:VCARD/gi);
|
|
183
170
|
if (!vcardBlocks)
|
|
184
171
|
continue;
|
|
@@ -310,7 +297,7 @@ async function resolveContactByUid(addressBookName, uid) {
|
|
|
310
297
|
return {
|
|
311
298
|
href: hrefMatch[1],
|
|
312
299
|
etag,
|
|
313
|
-
vcardData: decodeXmlEntities(cardDataMatch[1]),
|
|
300
|
+
vcardData: decodeXmlEntities(cardDataMatch[1]).trim(),
|
|
314
301
|
};
|
|
315
302
|
}
|
|
316
303
|
// ---------------------------------------------------------------------------
|
|
@@ -503,7 +490,7 @@ export const getContactTool = {
|
|
|
503
490
|
handler: async (args) => {
|
|
504
491
|
try {
|
|
505
492
|
const { vcardData } = await resolveContactByUid(args.addressBookName, args.uid);
|
|
506
|
-
const contact = parseVCardBlock(
|
|
493
|
+
const contact = parseVCardBlock(vcardData);
|
|
507
494
|
if (!contact) {
|
|
508
495
|
throw new Error(`Contact with UID "${args.uid}" could not be parsed`);
|
|
509
496
|
}
|
|
@@ -598,49 +585,42 @@ export const createContactTool = {
|
|
|
598
585
|
const given = args.firstName ? escapeVCardValue(args.firstName) : '';
|
|
599
586
|
const prefix = args.prefix ? escapeVCardValue(args.prefix) : '';
|
|
600
587
|
const suffix = args.suffix ? escapeVCardValue(args.suffix) : '';
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const street = addr.street ? escapeVCardValue(addr.street) : '';
|
|
618
|
-
const city = addr.city ? escapeVCardValue(addr.city) : '';
|
|
619
|
-
const region = addr.region ? escapeVCardValue(addr.region) : '';
|
|
620
|
-
const postalCode = addr.postalCode ? escapeVCardValue(addr.postalCode) : '';
|
|
621
|
-
const country = addr.country ? escapeVCardValue(addr.country) : '';
|
|
622
|
-
vcard += `\r\nADR${typeParam}:;;${street};${city};${region};${postalCode};${country}`;
|
|
623
|
-
}
|
|
588
|
+
const properties = [
|
|
589
|
+
property('VERSION', '3.0'),
|
|
590
|
+
property('PRODID', '-//AIquila//MCP Server//EN'),
|
|
591
|
+
property('UID', contactUid),
|
|
592
|
+
property('REV', now),
|
|
593
|
+
property('FN', escapeVCardValue(args.fullName)),
|
|
594
|
+
property('N', `${family};${given};;${prefix};${suffix}`),
|
|
595
|
+
];
|
|
596
|
+
for (const email of args.emails ?? []) {
|
|
597
|
+
properties.push(typedProperty('EMAIL', sanitizeVCardUriValue(email.value), email.type));
|
|
598
|
+
}
|
|
599
|
+
for (const phone of args.phones ?? []) {
|
|
600
|
+
properties.push(typedProperty('TEL', sanitizeVCardUriValue(phone.value), phone.type));
|
|
601
|
+
}
|
|
602
|
+
for (const addr of args.addresses ?? []) {
|
|
603
|
+
properties.push(typedProperty('ADR', buildAdrValue(addr), addr.type));
|
|
624
604
|
}
|
|
625
605
|
if (args.org) {
|
|
626
|
-
|
|
606
|
+
properties.push(property('ORG', escapeVCardValue(args.org)));
|
|
627
607
|
}
|
|
628
608
|
if (args.title) {
|
|
629
|
-
|
|
609
|
+
properties.push(property('TITLE', escapeVCardValue(args.title)));
|
|
630
610
|
}
|
|
631
611
|
if (args.note) {
|
|
632
|
-
|
|
612
|
+
properties.push(property('NOTE', escapeVCardValue(args.note)));
|
|
633
613
|
}
|
|
634
614
|
if (args.birthday) {
|
|
635
|
-
|
|
615
|
+
properties.push(property('BDAY', sanitizeVCardUriValue(args.birthday)));
|
|
636
616
|
}
|
|
637
617
|
if (args.url) {
|
|
638
|
-
|
|
618
|
+
properties.push(property('URL', sanitizeVCardUriValue(args.url)));
|
|
639
619
|
}
|
|
640
620
|
if (args.categories && args.categories.length > 0) {
|
|
641
|
-
|
|
621
|
+
properties.push(property('CATEGORIES', args.categories.map(escapeVCardValue).join(',')));
|
|
642
622
|
}
|
|
643
|
-
vcard
|
|
623
|
+
const vcard = serializeVCard({ properties });
|
|
644
624
|
const response = await fetchCalDAV(cardDavUrl, {
|
|
645
625
|
method: 'PUT',
|
|
646
626
|
body: vcard,
|
|
@@ -741,80 +721,58 @@ export const updateContactTool = {
|
|
|
741
721
|
try {
|
|
742
722
|
const config = getNextcloudConfig();
|
|
743
723
|
const { href, etag, vcardData } = await resolveContactByUid(args.addressBookName, args.uid);
|
|
744
|
-
|
|
724
|
+
const doc = parseVCard(vcardData);
|
|
745
725
|
// Update FN
|
|
746
726
|
if (args.fullName !== undefined) {
|
|
747
|
-
|
|
727
|
+
setProperty(doc, 'FN', escapeVCardValue(args.fullName));
|
|
748
728
|
}
|
|
749
|
-
// Update N (structured name)
|
|
729
|
+
// Update N (structured name), preserving the components we do not touch
|
|
750
730
|
if (args.firstName !== undefined || args.lastName !== undefined) {
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
const existingParts = nMatch ? nMatch[2].split(';') : ['', '', '', '', ''];
|
|
731
|
+
const existing = getProperty(doc, 'N');
|
|
732
|
+
const parts = existing ? existing.value.split(';') : ['', '', '', '', ''];
|
|
754
733
|
if (args.lastName !== undefined) {
|
|
755
|
-
|
|
734
|
+
parts[0] = args.lastName ? escapeVCardValue(args.lastName) : '';
|
|
756
735
|
}
|
|
757
736
|
if (args.firstName !== undefined) {
|
|
758
|
-
|
|
737
|
+
parts[1] = args.firstName ? escapeVCardValue(args.firstName) : '';
|
|
759
738
|
}
|
|
760
|
-
|
|
739
|
+
setProperty(doc, 'N', parts.slice(0, 5).join(';'));
|
|
761
740
|
}
|
|
762
741
|
// Update simple properties
|
|
763
742
|
if (args.org !== undefined) {
|
|
764
|
-
|
|
743
|
+
setProperty(doc, 'ORG', args.org ? escapeVCardValue(args.org) : null);
|
|
765
744
|
}
|
|
766
745
|
if (args.title !== undefined) {
|
|
767
|
-
|
|
746
|
+
setProperty(doc, 'TITLE', args.title ? escapeVCardValue(args.title) : null);
|
|
768
747
|
}
|
|
769
748
|
if (args.note !== undefined) {
|
|
770
|
-
|
|
749
|
+
setProperty(doc, 'NOTE', args.note ? escapeVCardValue(args.note) : null);
|
|
771
750
|
}
|
|
772
751
|
if (args.birthday !== undefined) {
|
|
773
|
-
|
|
752
|
+
setProperty(doc, 'BDAY', args.birthday ? sanitizeVCardUriValue(args.birthday) : null);
|
|
774
753
|
}
|
|
775
754
|
if (args.url !== undefined) {
|
|
776
|
-
|
|
755
|
+
setProperty(doc, 'URL', args.url ? sanitizeVCardUriValue(args.url) : null);
|
|
777
756
|
}
|
|
778
757
|
// Update multi-valued properties (replace all)
|
|
779
758
|
if (args.emails !== undefined) {
|
|
780
|
-
|
|
781
|
-
for (const email of args.emails) {
|
|
782
|
-
const typeParam = email.type ? `;TYPE=${email.type}` : '';
|
|
783
|
-
const line = `EMAIL${typeParam}:${email.value}`;
|
|
784
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
785
|
-
}
|
|
759
|
+
replaceAll(doc, 'EMAIL', args.emails.map((e) => typedProperty('EMAIL', sanitizeVCardUriValue(e.value), e.type)));
|
|
786
760
|
}
|
|
787
761
|
if (args.phones !== undefined) {
|
|
788
|
-
|
|
789
|
-
for (const phone of args.phones) {
|
|
790
|
-
const typeParam = phone.type ? `;TYPE=${phone.type}` : '';
|
|
791
|
-
const line = `TEL${typeParam}:${phone.value}`;
|
|
792
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
793
|
-
}
|
|
762
|
+
replaceAll(doc, 'TEL', args.phones.map((p) => typedProperty('TEL', sanitizeVCardUriValue(p.value), p.type)));
|
|
794
763
|
}
|
|
795
764
|
if (args.addresses !== undefined) {
|
|
796
|
-
|
|
797
|
-
for (const addr of args.addresses) {
|
|
798
|
-
const typeParam = addr.type ? `;TYPE=${addr.type}` : '';
|
|
799
|
-
const street = addr.street ? escapeVCardValue(addr.street) : '';
|
|
800
|
-
const city = addr.city ? escapeVCardValue(addr.city) : '';
|
|
801
|
-
const region = addr.region ? escapeVCardValue(addr.region) : '';
|
|
802
|
-
const postalCode = addr.postalCode ? escapeVCardValue(addr.postalCode) : '';
|
|
803
|
-
const country = addr.country ? escapeVCardValue(addr.country) : '';
|
|
804
|
-
const line = `ADR${typeParam}:;;${street};${city};${region};${postalCode};${country}`;
|
|
805
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
806
|
-
}
|
|
765
|
+
replaceAll(doc, 'ADR', args.addresses.map((a) => typedProperty('ADR', buildAdrValue(a), a.type)));
|
|
807
766
|
}
|
|
808
767
|
if (args.categories !== undefined) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
modified = modified.replace(/END:VCARD/i, `${line}\r\nEND:VCARD`);
|
|
813
|
-
}
|
|
768
|
+
replaceAll(doc, 'CATEGORIES', args.categories.length > 0
|
|
769
|
+
? [property('CATEGORIES', args.categories.map(escapeVCardValue).join(','))]
|
|
770
|
+
: []);
|
|
814
771
|
}
|
|
815
772
|
// Update REV timestamp
|
|
816
773
|
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
817
|
-
|
|
774
|
+
setProperty(doc, 'REV', now);
|
|
775
|
+
const modified = serializeVCard(doc);
|
|
818
776
|
const putUrl = `${config.url}${href}`;
|
|
819
777
|
const putResponse = await fetchCalDAV(putUrl, {
|
|
820
778
|
method: 'PUT',
|
package/dist/tools/dav-utils.js
CHANGED
|
@@ -7,24 +7,46 @@
|
|
|
7
7
|
* case-insensitive for that sequence.
|
|
8
8
|
*/
|
|
9
9
|
export function escapeDavValue(value) {
|
|
10
|
-
return value
|
|
10
|
+
return (value
|
|
11
11
|
.replace(/\\/g, '\\\\')
|
|
12
12
|
.replace(/;/g, '\\;')
|
|
13
13
|
.replace(/,/g, '\\,')
|
|
14
|
-
|
|
14
|
+
// A raw CR would terminate the content line and make the payload
|
|
15
|
+
// unparseable, so CRLF and lone CR both collapse to an escaped newline.
|
|
16
|
+
.replace(/\r\n|\r|\n/g, '\\n'));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize a value for a URI- or date-valued property (URL, TEL, EMAIL, BDAY).
|
|
20
|
+
*
|
|
21
|
+
* Text escaping must NOT be applied to these: `;` and `,` carry no special
|
|
22
|
+
* meaning in their values, and escaping them leaves a literal backslash in the
|
|
23
|
+
* URI once a strict parser reads it back. All that is actually required is that
|
|
24
|
+
* the value cannot terminate the content line.
|
|
25
|
+
*/
|
|
26
|
+
export function sanitizeDavUriValue(value) {
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
return value.replace(/[\x00-\x1f\x7f]/g, '');
|
|
15
29
|
}
|
|
16
30
|
export function unescapeDavValue(value, options) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
// Single pass: a chained replace would rewrite the `\n` inside an escaped
|
|
32
|
+
// backslash sequence (`\\n` — a literal backslash followed by `n`) into a
|
|
33
|
+
// newline before the backslash itself was unescaped.
|
|
34
|
+
const newlinePattern = options?.caseInsensitiveNewline ? /n/i : /n/;
|
|
35
|
+
return value.replace(/\\(.)/g, (match, char) => {
|
|
36
|
+
if (newlinePattern.test(char))
|
|
37
|
+
return '\n';
|
|
38
|
+
if (char === ',' || char === ';' || char === '\\')
|
|
39
|
+
return char;
|
|
40
|
+
return match;
|
|
41
|
+
});
|
|
22
42
|
}
|
|
23
43
|
/** Escape for iCalendar (VEVENT, VTODO) properties. */
|
|
24
44
|
export const escapeICalValue = escapeDavValue;
|
|
25
45
|
/** Unescape iCalendar property values (case-sensitive \\n). */
|
|
26
46
|
export const unescapeICalValue = (value) => unescapeDavValue(value);
|
|
27
|
-
/** Escape for vCard properties. */
|
|
47
|
+
/** Escape for vCard text properties (FN, N, ADR, ORG, NOTE, CATEGORIES, ...). */
|
|
28
48
|
export const escapeVCardValue = escapeDavValue;
|
|
49
|
+
/** Sanitize vCard URI/date properties (URL, TEL, EMAIL, BDAY) — no escaping. */
|
|
50
|
+
export const sanitizeVCardUriValue = sanitizeDavUriValue;
|
|
29
51
|
/** Unescape vCard property values (case-insensitive \\n per RFC 6350). */
|
|
30
52
|
export const unescapeVCardValue = (value) => unescapeDavValue(value, { caseInsensitiveNewline: true });
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Minimal vCard (RFC 6350 / RFC 2426) parser and serializer.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the regex string-surgery that used to mutate vCard text in place.
|
|
6
|
+
* That approach produced bodies SabreDAV rejected with `415 Unsupported Media
|
|
7
|
+
* Type` (GH #396): it mixed line endings, never re-folded long lines, dropped
|
|
8
|
+
* property parameters, and could not see grouped properties (`item1.TEL`).
|
|
9
|
+
*
|
|
10
|
+
* The model is deliberately flat: a vCard is an ordered list of properties,
|
|
11
|
+
* including the `BEGIN`, `VERSION` and `END` lines. That keeps round-tripping a
|
|
12
|
+
* card we did not author byte-stable apart from re-folding, which matters
|
|
13
|
+
* because we PUT back whatever the server gave us.
|
|
14
|
+
*/
|
|
15
|
+
/** Maximum octets per line before folding (RFC 6350 §3.2). */
|
|
16
|
+
const MAX_LINE_OCTETS = 75;
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Parsing
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Split a content line at the first colon that is not inside a quoted
|
|
22
|
+
* parameter value. `TEL;TYPE="voice:work";VALUE=uri:tel:+41...` must split at
|
|
23
|
+
* the colon before `tel:`, not at either of the earlier ones.
|
|
24
|
+
*/
|
|
25
|
+
function splitAtValueColon(line) {
|
|
26
|
+
let inQuotes = false;
|
|
27
|
+
for (let i = 0; i < line.length; i++) {
|
|
28
|
+
const ch = line[i];
|
|
29
|
+
if (ch === '"') {
|
|
30
|
+
inQuotes = !inQuotes;
|
|
31
|
+
}
|
|
32
|
+
else if (ch === ':' && !inQuotes) {
|
|
33
|
+
return [line.slice(0, i), line.slice(i + 1)];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Split the part before the value colon on unquoted semicolons. */
|
|
39
|
+
function splitParams(head) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
let current = '';
|
|
42
|
+
let inQuotes = false;
|
|
43
|
+
for (const ch of head) {
|
|
44
|
+
if (ch === '"') {
|
|
45
|
+
inQuotes = !inQuotes;
|
|
46
|
+
current += ch;
|
|
47
|
+
}
|
|
48
|
+
else if (ch === ';' && !inQuotes) {
|
|
49
|
+
segments.push(current);
|
|
50
|
+
current = '';
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
current += ch;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
segments.push(current);
|
|
57
|
+
return segments;
|
|
58
|
+
}
|
|
59
|
+
function parseLine(line) {
|
|
60
|
+
const split = splitAtValueColon(line);
|
|
61
|
+
if (!split)
|
|
62
|
+
return null;
|
|
63
|
+
const [head, value] = split;
|
|
64
|
+
const segments = splitParams(head);
|
|
65
|
+
const nameToken = segments[0];
|
|
66
|
+
if (!nameToken)
|
|
67
|
+
return null;
|
|
68
|
+
let group;
|
|
69
|
+
let name = nameToken;
|
|
70
|
+
const dot = nameToken.indexOf('.');
|
|
71
|
+
if (dot > 0) {
|
|
72
|
+
group = nameToken.slice(0, dot);
|
|
73
|
+
name = nameToken.slice(dot + 1);
|
|
74
|
+
}
|
|
75
|
+
if (!name)
|
|
76
|
+
return null;
|
|
77
|
+
const params = [];
|
|
78
|
+
for (const segment of segments.slice(1)) {
|
|
79
|
+
if (!segment)
|
|
80
|
+
continue;
|
|
81
|
+
const eq = segment.indexOf('=');
|
|
82
|
+
if (eq === -1) {
|
|
83
|
+
params.push([segment, '']);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
params.push([segment.slice(0, eq), segment.slice(eq + 1)]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const upper = name.toUpperCase();
|
|
90
|
+
return {
|
|
91
|
+
group,
|
|
92
|
+
name: upper,
|
|
93
|
+
...(name === upper ? {} : { rawName: name }),
|
|
94
|
+
params,
|
|
95
|
+
value,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse vCard text into a document.
|
|
100
|
+
*
|
|
101
|
+
* Normalizes all line-ending flavours, unfolds continuation lines, and skips
|
|
102
|
+
* blank lines. Unparseable lines are dropped rather than throwing: we are
|
|
103
|
+
* usually handling a card written by some other client and would rather
|
|
104
|
+
* preserve the rest than fail the whole update.
|
|
105
|
+
*/
|
|
106
|
+
export function parseVCard(text) {
|
|
107
|
+
const unfolded = text
|
|
108
|
+
.replace(/\r\n|\r/g, '\n')
|
|
109
|
+
.replace(/\n[ \t]/g, '')
|
|
110
|
+
.trim();
|
|
111
|
+
const properties = [];
|
|
112
|
+
for (const line of unfolded.split('\n')) {
|
|
113
|
+
if (!line.trim())
|
|
114
|
+
continue;
|
|
115
|
+
const property = parseLine(line);
|
|
116
|
+
if (property)
|
|
117
|
+
properties.push(property);
|
|
118
|
+
}
|
|
119
|
+
return { properties };
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Serialization
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/**
|
|
125
|
+
* Fold a content line to {@link MAX_LINE_OCTETS} octets, splitting only on
|
|
126
|
+
* whole code points so multi-byte UTF-8 sequences are never cut in half.
|
|
127
|
+
* Continuation lines carry a single leading space, which counts toward the
|
|
128
|
+
* limit.
|
|
129
|
+
*/
|
|
130
|
+
function foldLine(line) {
|
|
131
|
+
if (Buffer.byteLength(line, 'utf8') <= MAX_LINE_OCTETS)
|
|
132
|
+
return line;
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let current = '';
|
|
135
|
+
let currentOctets = 0;
|
|
136
|
+
// First line has the full budget; continuation lines lose one octet to the
|
|
137
|
+
// leading space.
|
|
138
|
+
let limit = MAX_LINE_OCTETS;
|
|
139
|
+
for (const char of line) {
|
|
140
|
+
const octets = Buffer.byteLength(char, 'utf8');
|
|
141
|
+
if (currentOctets + octets > limit) {
|
|
142
|
+
chunks.push(current);
|
|
143
|
+
current = '';
|
|
144
|
+
currentOctets = 0;
|
|
145
|
+
limit = MAX_LINE_OCTETS - 1;
|
|
146
|
+
}
|
|
147
|
+
current += char;
|
|
148
|
+
currentOctets += octets;
|
|
149
|
+
}
|
|
150
|
+
chunks.push(current);
|
|
151
|
+
return chunks.join('\r\n ');
|
|
152
|
+
}
|
|
153
|
+
function serializeProperty(property) {
|
|
154
|
+
const bare = property.rawName ?? property.name;
|
|
155
|
+
const name = property.group ? `${property.group}.${bare}` : bare;
|
|
156
|
+
const params = property.params
|
|
157
|
+
.map(([key, value]) => (value === '' ? `;${key}` : `;${key}=${value}`))
|
|
158
|
+
.join('');
|
|
159
|
+
return foldLine(`${name}${params}:${property.value}`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Serialize a document to wire format: CRLF throughout, folded, and with the
|
|
163
|
+
* structural properties forced into the order SabreDAV's strict MimeDir parser
|
|
164
|
+
* expects (`BEGIN` first, `VERSION` second, `END` last).
|
|
165
|
+
*/
|
|
166
|
+
export function serializeVCard(doc) {
|
|
167
|
+
const body = doc.properties.filter((p) => p.name !== 'BEGIN' && p.name !== 'END' && p.name !== 'VERSION');
|
|
168
|
+
const version = doc.properties.find((p) => p.name === 'VERSION');
|
|
169
|
+
const ordered = [
|
|
170
|
+
{ name: 'BEGIN', params: [], value: 'VCARD' },
|
|
171
|
+
version ?? { name: 'VERSION', params: [], value: '3.0' },
|
|
172
|
+
...body,
|
|
173
|
+
{ name: 'END', params: [], value: 'VCARD' },
|
|
174
|
+
];
|
|
175
|
+
return ordered.map(serializeProperty).join('\r\n') + '\r\n';
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Mutation helpers
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/** Build a property, defaulting the parameter list. */
|
|
181
|
+
export function property(name, value, params = []) {
|
|
182
|
+
return { name: name.toUpperCase(), params, value };
|
|
183
|
+
}
|
|
184
|
+
/** First property with the given name, in any group. */
|
|
185
|
+
export function getProperty(doc, name) {
|
|
186
|
+
const upper = name.toUpperCase();
|
|
187
|
+
return doc.properties.find((p) => p.name === upper);
|
|
188
|
+
}
|
|
189
|
+
/** All values of a parameter across a property's (possibly repeated) entries. */
|
|
190
|
+
export function getParamValues(prop, paramName) {
|
|
191
|
+
const upper = paramName.toUpperCase();
|
|
192
|
+
return prop.params
|
|
193
|
+
.filter(([key]) => key.toUpperCase() === upper)
|
|
194
|
+
.flatMap(([, value]) => value.replace(/^"|"$/g, '').split(','))
|
|
195
|
+
.map((v) => v.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
/** Index just before the terminating `END:VCARD`, or the end of the list. */
|
|
199
|
+
function insertionIndex(doc) {
|
|
200
|
+
const end = doc.properties.findIndex((p) => p.name === 'END');
|
|
201
|
+
return end === -1 ? doc.properties.length : end;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Replace the value of a single-valued property, or remove it when `value` is
|
|
205
|
+
* null. Existing parameters and the group prefix are preserved — replacing
|
|
206
|
+
* `NOTE;CHARSET=UTF-8:old` must not silently produce `NOTE:new`.
|
|
207
|
+
*/
|
|
208
|
+
export function setProperty(doc, name, value) {
|
|
209
|
+
const upper = name.toUpperCase();
|
|
210
|
+
if (value === null) {
|
|
211
|
+
doc.properties = doc.properties.filter((p) => p.name !== upper);
|
|
212
|
+
removeOrphanedLabels(doc);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const existing = doc.properties.find((p) => p.name === upper);
|
|
216
|
+
if (existing) {
|
|
217
|
+
existing.value = value;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
doc.properties.splice(insertionIndex(doc), 0, property(upper, value));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Replace every instance of a multi-valued property (EMAIL, TEL, ADR, ...)
|
|
224
|
+
* with a new set. Grouped instances are removed too, so re-setting phones does
|
|
225
|
+
* not leave the Contacts app's `item1.TEL` entries behind alongside the new
|
|
226
|
+
* ones.
|
|
227
|
+
*/
|
|
228
|
+
export function replaceAll(doc, name, replacements) {
|
|
229
|
+
const upper = name.toUpperCase();
|
|
230
|
+
doc.properties = doc.properties.filter((p) => p.name !== upper);
|
|
231
|
+
removeOrphanedLabels(doc);
|
|
232
|
+
doc.properties.splice(insertionIndex(doc), 0, ...replacements);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Drop `X-ABLabel` properties whose group no longer has anything to label.
|
|
236
|
+
* Removing `item1.TEL` without this leaves a dangling `item1.X-ABLabel`, which
|
|
237
|
+
* the Contacts app renders as an empty labelled field.
|
|
238
|
+
*/
|
|
239
|
+
function removeOrphanedLabels(doc) {
|
|
240
|
+
const groupsInUse = new Set(doc.properties.filter((p) => p.group && p.name !== 'X-ABLABEL').map((p) => p.group));
|
|
241
|
+
doc.properties = doc.properties.filter((p) => p.name !== 'X-ABLABEL' || !p.group || groupsInUse.has(p.group));
|
|
242
|
+
}
|
package/dist/transports/http.js
CHANGED
|
@@ -9,10 +9,16 @@ 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 { isPublicRequest } from './lazy-auth.js';
|
|
12
13
|
import { logger } from '../logger.js';
|
|
13
14
|
import { fetchStatus } from '../client/ocs.js';
|
|
14
15
|
const DEFAULT_PORT = 3339;
|
|
15
16
|
const MCP_PATH = '/mcp';
|
|
17
|
+
const SCOPES_SUPPORTED = ['mcp:tools', 'mcp:resources', 'mcp:prompts'];
|
|
18
|
+
// Scope advertised in the WWW-Authenticate challenge. Clients request exactly
|
|
19
|
+
// this on authorisation; omitting it makes them fall back to the full
|
|
20
|
+
// `scopes_supported` union, which produces an over-broad consent prompt.
|
|
21
|
+
const CHALLENGE_SCOPE = 'mcp:tools';
|
|
16
22
|
// TLS error codes that indicate a self-signed or untrusted certificate.
|
|
17
23
|
// Network errors (ECONNREFUSED, ETIMEDOUT) are not included — they mean the
|
|
18
24
|
// proxy is not yet reachable, which is transient and should not log a warning.
|
|
@@ -81,6 +87,10 @@ export async function startHttp() {
|
|
|
81
87
|
const port = parseInt(process.env.MCP_PORT || String(DEFAULT_PORT), 10);
|
|
82
88
|
const host = process.env.MCP_HOST || '0.0.0.0';
|
|
83
89
|
const authEnabled = process.env.MCP_AUTH_ENABLED === 'true';
|
|
90
|
+
// Lazy authentication is on by default: clients need to inspect a connector
|
|
91
|
+
// before signing in. Operators who treat /mcp as a fully closed endpoint can
|
|
92
|
+
// set MCP_LAZY_AUTH=false to require a bearer token on every JSON-RPC method.
|
|
93
|
+
const lazyAuthEnabled = process.env.MCP_LAZY_AUTH !== 'false';
|
|
84
94
|
if (!authEnabled) {
|
|
85
95
|
if (process.env.MCP_ALLOW_UNAUTHENTICATED !== 'true') {
|
|
86
96
|
throw new Error('HTTP transport requires MCP_AUTH_ENABLED=true. ' +
|
|
@@ -220,7 +230,7 @@ export async function startHttp() {
|
|
|
220
230
|
issuerUrl: new URL(issuerUrl),
|
|
221
231
|
resourceServerUrl: new URL(MCP_PATH, issuerUrl),
|
|
222
232
|
serviceDocumentationUrl: new URL('https://github.com/elgorro/aiquila'),
|
|
223
|
-
scopesSupported:
|
|
233
|
+
scopesSupported: SCOPES_SUPPORTED,
|
|
224
234
|
}));
|
|
225
235
|
// Fallback: serve protected resource metadata at the root well-known path too,
|
|
226
236
|
// so clients that use the server root URL (without /mcp) can discover the resource.
|
|
@@ -229,7 +239,7 @@ export async function startHttp() {
|
|
|
229
239
|
res.json({
|
|
230
240
|
resource: new URL(MCP_PATH, issuerUrl).href,
|
|
231
241
|
authorization_servers: [issuerUrl],
|
|
232
|
-
scopes_supported:
|
|
242
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
233
243
|
resource_documentation: 'https://github.com/elgorro/aiquila',
|
|
234
244
|
});
|
|
235
245
|
});
|
|
@@ -265,9 +275,44 @@ export async function startHttp() {
|
|
|
265
275
|
logger.warn({ status: res.statusCode, rpcMethod: req.body?.method }, '[mcp] Request rejected');
|
|
266
276
|
}
|
|
267
277
|
});
|
|
278
|
+
// The SDK appends `scope="..."` to the challenge only when
|
|
279
|
+
// `requiredScopes` is set — but setting that would also *enforce* the
|
|
280
|
+
// scope, and 403 every token issued before scopes were requested (plus
|
|
281
|
+
// the internal service token, which carries none). Advertise the scope
|
|
282
|
+
// without enforcing it by decorating the header on its way out.
|
|
283
|
+
const setHeader = res.setHeader.bind(res);
|
|
284
|
+
res.setHeader = (name, value) => {
|
|
285
|
+
if (typeof name === 'string' &&
|
|
286
|
+
name.toLowerCase() === 'www-authenticate' &&
|
|
287
|
+
typeof value === 'string' &&
|
|
288
|
+
!value.includes('scope=')) {
|
|
289
|
+
value = `${value}, scope="${CHALLENGE_SCOPE}"`;
|
|
290
|
+
}
|
|
291
|
+
return setHeader(name, value);
|
|
292
|
+
};
|
|
268
293
|
next();
|
|
269
294
|
},
|
|
270
|
-
|
|
295
|
+
// Lazy-auth gate: the handshake, capability listings, and public tools are
|
|
296
|
+
// served anonymously so clients can inspect the connector before sign-in.
|
|
297
|
+
// Everything else falls through to requireBearerAuth below.
|
|
298
|
+
// Omitted entirely when MCP_LAZY_AUTH=false, restoring a chain in which
|
|
299
|
+
// every JSON-RPC method requires a bearer token.
|
|
300
|
+
...(lazyAuthEnabled
|
|
301
|
+
? [
|
|
302
|
+
async (req, res, next) => {
|
|
303
|
+
if (!req.headers.authorization && isPublicRequest(req.body)) {
|
|
304
|
+
logger.debug({ rpcMethod: req.body?.method }, '[mcp] Public request (unauthenticated)');
|
|
305
|
+
await handleMcpRequest(req, res);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
next();
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
: []),
|
|
312
|
+
requireBearerAuth({
|
|
313
|
+
verifier: provider,
|
|
314
|
+
resourceMetadataUrl: new URL('/.well-known/oauth-protected-resource/mcp', issuerUrl).href,
|
|
315
|
+
}),
|
|
271
316
|
async (req, res) => {
|
|
272
317
|
logger.debug({ method: req.method, rpcMethod: req.body?.method }, '[mcp] Request received');
|
|
273
318
|
await handleMcpRequest(req, res);
|
|
@@ -288,6 +333,9 @@ export async function startHttp() {
|
|
|
288
333
|
if (authEnabled) {
|
|
289
334
|
const tp = process.env.MCP_TRUST_PROXY;
|
|
290
335
|
logger.info({ issuer: process.env.MCP_AUTH_ISSUER }, 'OAuth 2.0 authentication enabled');
|
|
336
|
+
logger.info({ lazyAuth: lazyAuthEnabled }, lazyAuthEnabled
|
|
337
|
+
? 'Lazy auth enabled — tools/list and public tools are readable without a token (set MCP_LAZY_AUTH=false to require one)'
|
|
338
|
+
: 'Lazy auth disabled — every JSON-RPC method requires a bearer token');
|
|
291
339
|
logger.info({ trustProxy: tp && tp !== 'false' ? tp : 'disabled' }, 'Trust proxy setting (set MCP_TRUST_PROXY=1 if behind a reverse proxy)');
|
|
292
340
|
if (hasStaticClient) {
|
|
293
341
|
logger.info({ clientId: process.env.MCP_CLIENT_ID }, 'Static OAuth client configured');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Lazy authentication (mixed auth) for the Streamable HTTP transport.
|
|
4
|
+
*
|
|
5
|
+
* Unauthenticated clients may complete the MCP handshake, enumerate the server's
|
|
6
|
+
* capabilities, and call the tools listed in PUBLIC_TOOLS. Anything else is
|
|
7
|
+
* refused at the HTTP layer with a 401 + WWW-Authenticate challenge, which is
|
|
8
|
+
* what makes clients render an inline "Connect" card and retry the same call
|
|
9
|
+
* once the user has authorised.
|
|
10
|
+
*
|
|
11
|
+
* The refusal has to be an HTTP status, so the check must run *before* the
|
|
12
|
+
* JSON-RPC message reaches the MCP SDK: once a tool handler is executing, its
|
|
13
|
+
* return value is already destined to be wrapped in a 200 response, and a 200
|
|
14
|
+
* carrying `isError: true` reads as an application-level failure, not an auth
|
|
15
|
+
* challenge.
|
|
16
|
+
*
|
|
17
|
+
* @see https://claude.com/docs/connectors/building/lazy-authentication
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* JSON-RPC methods reachable without a token. These describe the server rather
|
|
21
|
+
* than touching the Nextcloud instance behind it, so they leak nothing.
|
|
22
|
+
*/
|
|
23
|
+
const PUBLIC_METHODS = new Set([
|
|
24
|
+
'initialize',
|
|
25
|
+
'notifications/initialized',
|
|
26
|
+
'notifications/cancelled',
|
|
27
|
+
'ping',
|
|
28
|
+
'tools/list',
|
|
29
|
+
'resources/list',
|
|
30
|
+
'resources/templates/list',
|
|
31
|
+
'prompts/list',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Tools reachable without a token. Every other AIquila tool proxies to
|
|
35
|
+
* Nextcloud, so this set is deliberately tiny.
|
|
36
|
+
*/
|
|
37
|
+
const PUBLIC_TOOLS = new Set(['get_local_time']);
|
|
38
|
+
function isPublicMessage(msg) {
|
|
39
|
+
if (!msg || typeof msg !== 'object')
|
|
40
|
+
return false;
|
|
41
|
+
const method = msg.method;
|
|
42
|
+
if (typeof method !== 'string')
|
|
43
|
+
return false;
|
|
44
|
+
if (method === 'tools/call') {
|
|
45
|
+
const name = msg.params?.name;
|
|
46
|
+
return typeof name === 'string' && PUBLIC_TOOLS.has(name);
|
|
47
|
+
}
|
|
48
|
+
return PUBLIC_METHODS.has(method);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when every message in the request body is safe to serve anonymously.
|
|
52
|
+
*
|
|
53
|
+
* A JSON-RPC batch is a single HTTP request and therefore gets a single status
|
|
54
|
+
* code, so one protected message taints the whole batch. An empty or
|
|
55
|
+
* non-JSON-RPC body (a GET for the SSE stream, a DELETE to end a session) is
|
|
56
|
+
* never public — those still require a bearer token.
|
|
57
|
+
*/
|
|
58
|
+
export function isPublicRequest(body) {
|
|
59
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
60
|
+
if (messages.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
return messages.every(isPublicMessage);
|
|
63
|
+
}
|