@fruggr/zendesk-mcp-server 3.7.0 → 3.9.0
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 +32 -22
- package/dist/THIRD-PARTY-NOTICES.md +5 -5
- package/dist/{article-sections-CzTODYsS.js → article-sections-DA7_Jm2H.js} +65 -34
- package/dist/{http-D-Xy9BWD.js → http-DuRcQrOc.js} +2809 -388
- package/dist/index.js +1370 -779
- package/dist/sbom.cdx.json +19 -19
- package/dist/{token-store-BqwB-30K.js → token-store-EwRwZiJO.js} +1 -1
- package/package.json +9 -9
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-BbXq-jo1.js";
|
|
3
|
-
import { A as
|
|
3
|
+
import { $ as _enum, A as ResourceTemplate, B as stampErrorBrands, D as createMcpHandler, E as WebStandardStreamableHTTPServerTransport, F as ProtocolErrorCode, G as ResultSchema, H as PaginatedRequestParamsSchema, I as ResourceNotFoundError, J as toStrictStandardSchema, K as buildInstructions, L as assertValidCacheHint, M as OAuthError, N as OAuthErrorCode, O as isLegacyRequest, P as ProtocolError, Q as errnoCode, R as brandedHasInstance, S as deadlineSignal, T as DEFAULT_MAX_REQUEST_BODY_SIZE, U as PaginatedResultSchema, V as BaseRequestParamsSchema, W as ResourceSchema, X as STARTUP_DOCS, Y as compareCodeUnits, Z as createStartupError, _ as createAllTools, a as toolEnv, at as object, b as zendeskGet, ct as union, et as array, g as silentLogger, h as readPackageInfo, i as defaultServerFactory, it as number, j as verifyBearerToken, k as McpServer, l as configDir, lt as unknown, m as createRequestStateCodec, n as listenedUris, nt as literal, o as watchGrantAlerts, ot as record, q as Namespace, r as createServerCaches, rt as looseObject, s as startSlaRuntime, st as string, t as createListenFeed, tt as boolean$1, v as createTopologyProvider, w as getOAuthUrls, x as REQUEST_TIMEOUT_MS, y as ZendeskApiError, z as isModernProtocolVersion } from "./index.js";
|
|
4
4
|
import { a as supportedScopes, i as requestedScope, n as generateCodeVerifier, o as escapeHtml, t as generateCodeChallenge } from "./pkce-DE_v9NQy.js";
|
|
5
5
|
import * as util from "node:util";
|
|
6
6
|
import { format, inspect } from "node:util";
|
|
7
7
|
import * as crypto$2 from "node:crypto";
|
|
8
8
|
import { createHash, createPrivateKey, createSecretKey, hkdfSync, randomBytes, randomFillSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { accessSync, chmodSync, constants, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
12
13
|
import { STATUS_CODES, createServer } from "node:http";
|
|
13
|
-
import { Http2ServerRequest, constants as constants$
|
|
14
|
+
import { Http2ServerRequest, constants as constants$2 } from "http2";
|
|
14
15
|
import { Readable } from "stream";
|
|
15
16
|
import crypto$1 from "crypto";
|
|
16
17
|
import { strict } from "node:assert";
|
|
@@ -315,7 +316,7 @@ var drainIncoming = (incoming) => {
|
|
|
315
316
|
incomingWithDrainState[incomingDraining] = true;
|
|
316
317
|
if (incoming instanceof Http2ServerRequest) {
|
|
317
318
|
try {
|
|
318
|
-
incoming.stream?.close?.(constants$
|
|
319
|
+
incoming.stream?.close?.(constants$2.NGHTTP2_NO_ERROR);
|
|
319
320
|
} catch {}
|
|
320
321
|
return;
|
|
321
322
|
}
|
|
@@ -505,7 +506,7 @@ var getRequestListener = (fetchCallback, options = {}) => {
|
|
|
505
506
|
};
|
|
506
507
|
};
|
|
507
508
|
//#endregion
|
|
508
|
-
//#region node_modules/.pnpm/@modelcontextprotocol+node@2.1.
|
|
509
|
+
//#region node_modules/.pnpm/@modelcontextprotocol+node@2.1.2_@modelcontextprotocol+server@2.4.0/node_modules/@modelcontextprotocol/node/dist/index.mjs
|
|
509
510
|
/**
|
|
510
511
|
* Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
|
|
511
512
|
* It supports both SSE streaming and direct HTTP responses.
|
|
@@ -848,8 +849,8 @@ function internalServerErrorResponse(id) {
|
|
|
848
849
|
}, { status: 500 });
|
|
849
850
|
}
|
|
850
851
|
//#endregion
|
|
851
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
852
|
-
const encoder$
|
|
852
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
853
|
+
const encoder$3 = new TextEncoder();
|
|
853
854
|
const decoder$2 = new TextDecoder();
|
|
854
855
|
const strictDecoder = new TextDecoder("utf-8", { fatal: !0 });
|
|
855
856
|
const UINT32_RANGE = 2 ** 32;
|
|
@@ -880,7 +881,7 @@ const NON_ASCII = /[^\x00-\x7f]/;
|
|
|
880
881
|
function encode$2(string) {
|
|
881
882
|
if (typeof string == "string" && string.length >= 128) {
|
|
882
883
|
if (NON_ASCII.test(string)) throw new TypeError("non-ASCII string encountered in encode()");
|
|
883
|
-
return encoder$
|
|
884
|
+
return encoder$3.encode(string);
|
|
884
885
|
}
|
|
885
886
|
const bytes = new Uint8Array(string.length);
|
|
886
887
|
for (let i = 0; i < string.length; i++) {
|
|
@@ -890,7 +891,7 @@ function encode$2(string) {
|
|
|
890
891
|
}
|
|
891
892
|
return bytes;
|
|
892
893
|
}
|
|
893
|
-
function encodeBase64(input, url = !1) {
|
|
894
|
+
function encodeBase64$1(input, url = !1) {
|
|
894
895
|
if (Uint8Array.prototype.toBase64) return input.toBase64({
|
|
895
896
|
alphabet: url ? "base64url" : "base64",
|
|
896
897
|
omitPadding: url
|
|
@@ -900,7 +901,7 @@ function encodeBase64(input, url = !1) {
|
|
|
900
901
|
const encoded = btoa(arr.join(""));
|
|
901
902
|
return url ? encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") : encoded;
|
|
902
903
|
}
|
|
903
|
-
function decodeBase64(encoded, url = !1) {
|
|
904
|
+
function decodeBase64$1(encoded, url = !1) {
|
|
904
905
|
if (Uint8Array.fromBase64) return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
|
|
905
906
|
if (url) {
|
|
906
907
|
if (encoded.includes("+") || encoded.includes("/")) throw new TypeError("Invalid base64url");
|
|
@@ -915,7 +916,7 @@ async function digest(algorithm, data) {
|
|
|
915
916
|
return new Uint8Array(await crypto.subtle.digest(subtleDigest, data));
|
|
916
917
|
}
|
|
917
918
|
//#endregion
|
|
918
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
919
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/util/errors.js
|
|
919
920
|
var errors_exports$1 = /* @__PURE__ */ __exportAll({
|
|
920
921
|
JOSEAlgNotAllowed: () => JOSEAlgNotAllowed,
|
|
921
922
|
JOSEError: () => JOSEError,
|
|
@@ -1033,20 +1034,20 @@ var JWSSignatureVerificationFailed$1 = class extends JOSEError {
|
|
|
1033
1034
|
}
|
|
1034
1035
|
};
|
|
1035
1036
|
//#endregion
|
|
1036
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1037
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/util/base64url.js
|
|
1037
1038
|
const invalid$2 = "The input to be decoded is not correctly encoded.";
|
|
1038
1039
|
function decode$2(input) {
|
|
1039
1040
|
try {
|
|
1040
|
-
return decodeBase64(typeof input == "string" ? input : decoder$2.decode(input), !0);
|
|
1041
|
+
return decodeBase64$1(typeof input == "string" ? input : decoder$2.decode(input), !0);
|
|
1041
1042
|
} catch (cause) {
|
|
1042
1043
|
throw new TypeError(invalid$2, { cause });
|
|
1043
1044
|
}
|
|
1044
1045
|
}
|
|
1045
1046
|
function encode$1(input) {
|
|
1046
|
-
return encodeBase64(typeof input == "string" ? encoder$
|
|
1047
|
+
return encodeBase64$1(typeof input == "string" ? encoder$3.encode(input) : input, !0);
|
|
1047
1048
|
}
|
|
1048
1049
|
//#endregion
|
|
1049
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1050
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/validate.js
|
|
1050
1051
|
function assertUint8Array(input, label) {
|
|
1051
1052
|
if (!(input instanceof Uint8Array)) throw new TypeError(`${label} must be an instance of Uint8Array`);
|
|
1052
1053
|
}
|
|
@@ -1138,7 +1139,7 @@ function serializeJoseHeader(Err, header) {
|
|
|
1138
1139
|
return [parsed, serialized];
|
|
1139
1140
|
}
|
|
1140
1141
|
//#endregion
|
|
1141
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1142
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/key.js
|
|
1142
1143
|
const tag = (key) => key[Symbol.toStringTag];
|
|
1143
1144
|
const jwkMatchesOp = (entry, key, usage) => {
|
|
1144
1145
|
const { alg } = entry;
|
|
@@ -1275,7 +1276,7 @@ async function rawKey(key, expected, usage, extractable = !1) {
|
|
|
1275
1276
|
return key instanceof Uint8Array && (key = await crypto.subtle.importKey("raw", key, expected, extractable, [usage])), checkCryptoKey(key, expected, usage), key;
|
|
1276
1277
|
}
|
|
1277
1278
|
//#endregion
|
|
1278
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1279
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/content_encryption.js
|
|
1279
1280
|
const generateCek = (enc) => crypto.getRandomValues(new Uint8Array(enc.cekBits >> 3));
|
|
1280
1281
|
function checkCekLength(cek, expected) {
|
|
1281
1282
|
const actual = cek.byteLength << 3;
|
|
@@ -1362,7 +1363,7 @@ async function decrypt$1(enc, cek, ciphertext, iv, tag, aad) {
|
|
|
1362
1363
|
}
|
|
1363
1364
|
}
|
|
1364
1365
|
//#endregion
|
|
1365
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1366
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/key_descriptor.js
|
|
1366
1367
|
function table(entries) {
|
|
1367
1368
|
const out = { __proto__: null };
|
|
1368
1369
|
for (const alg in entries) out[alg] = {
|
|
@@ -1372,7 +1373,7 @@ function table(entries) {
|
|
|
1372
1373
|
return out;
|
|
1373
1374
|
}
|
|
1374
1375
|
//#endregion
|
|
1375
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1376
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jwe_algorithms.js
|
|
1376
1377
|
const wrap = [["encrypt", "wrapKey"], ["decrypt", "unwrapKey"]];
|
|
1377
1378
|
const derive$1 = [[], ["deriveBits"]];
|
|
1378
1379
|
const none = [[], []];
|
|
@@ -1492,7 +1493,7 @@ function jweEncryption(enc) {
|
|
|
1492
1493
|
return (typeof enc == "string" ? ENC[enc] : void 0) ?? unsupported$1("enc", "Encryption Algorithm");
|
|
1493
1494
|
}
|
|
1494
1495
|
//#endregion
|
|
1495
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1496
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/key_management.js
|
|
1496
1497
|
function checkEcdhCryptoKey(key, usage) {
|
|
1497
1498
|
if (key.algorithm.name !== "ECDH" && key.algorithm.name !== "X25519") throw new TypeError("CryptoKey does not support this operation, its algorithm.name must be ECDH or X25519");
|
|
1498
1499
|
checkUsage(key, usage);
|
|
@@ -1687,7 +1688,7 @@ async function encryptKeyManagement(entry, enc, inputKey, joseHeader, providedCe
|
|
|
1687
1688
|
];
|
|
1688
1689
|
}
|
|
1689
1690
|
//#endregion
|
|
1690
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1691
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/deflate.js
|
|
1691
1692
|
function validateZip(joseHeader, protectedHeader) {
|
|
1692
1693
|
if (joseHeader.zip !== void 0 && joseHeader.zip !== "DEF") throw new JOSENotSupported("Unsupported JWE \"zip\" (Compression Algorithm) Header Parameter value.");
|
|
1693
1694
|
if (joseHeader.zip !== void 0 && !protectedHeader?.zip) throw new JWEInvalid("JWE \"zip\" (Compression Algorithm) Header Parameter MUST be in a protected header.");
|
|
@@ -1715,7 +1716,7 @@ async function decompress(input, maxLength) {
|
|
|
1715
1716
|
return supported("DecompressionStream"), transform(new DecompressionStream("deflate-raw"), input, maxLength);
|
|
1716
1717
|
}
|
|
1717
1718
|
//#endregion
|
|
1718
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1719
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jwe_decrypt.js
|
|
1719
1720
|
function shareJWE(jwe) {
|
|
1720
1721
|
const { protected: encodedProtected, ciphertext, iv, tag, aad } = jwe;
|
|
1721
1722
|
let protectedHeader;
|
|
@@ -1820,12 +1821,12 @@ async function decryptCompact(jwe, shared, key) {
|
|
|
1820
1821
|
}, shared, key);
|
|
1821
1822
|
}
|
|
1822
1823
|
//#endregion
|
|
1823
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1824
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwe/compact/decrypt.js
|
|
1824
1825
|
async function compactDecrypt(jwe, key, options) {
|
|
1825
1826
|
return decryptCompact(jwe, prepareDecrypt(options), key);
|
|
1826
1827
|
}
|
|
1827
1828
|
//#endregion
|
|
1828
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1829
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jwe_encrypt.js
|
|
1829
1830
|
function checkDisjoint(protectedHeader, unprotectedHeader, sharedUnprotectedHeader) {
|
|
1830
1831
|
if (!isDisjoint(protectedHeader, unprotectedHeader, sharedUnprotectedHeader)) throw new JWEInvalid("JWE Protected, JWE Shared Unprotected and JWE Per-Recipient Header Parameter names must be disjoint");
|
|
1831
1832
|
}
|
|
@@ -1896,7 +1897,7 @@ function compactJWE(jwe) {
|
|
|
1896
1897
|
].join(".");
|
|
1897
1898
|
}
|
|
1898
1899
|
//#endregion
|
|
1899
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1900
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jws_algorithms.js
|
|
1900
1901
|
const sig = [["verify"], ["sign"]];
|
|
1901
1902
|
function hmac(bits) {
|
|
1902
1903
|
const subtle = {
|
|
@@ -1986,7 +1987,7 @@ function jwsAlgorithm(alg) {
|
|
|
1986
1987
|
return entry;
|
|
1987
1988
|
}
|
|
1988
1989
|
//#endregion
|
|
1989
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
1990
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jws_verify.js
|
|
1990
1991
|
function prepareVerify(options) {
|
|
1991
1992
|
return [options && validateAlgorithms("algorithms", options.algorithms), options?.crit];
|
|
1992
1993
|
}
|
|
@@ -2041,13 +2042,13 @@ async function verifyCompact(jws, shared, key) {
|
|
|
2041
2042
|
}, shared, key, encodeCompactUnencodedPayload);
|
|
2042
2043
|
}
|
|
2043
2044
|
//#endregion
|
|
2044
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2045
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jws/compact/verify.js
|
|
2045
2046
|
async function compactVerify(jws, key, options) {
|
|
2046
2047
|
const [result] = await verifyCompact(jws, prepareVerify(options), key);
|
|
2047
2048
|
return result;
|
|
2048
2049
|
}
|
|
2049
2050
|
//#endregion
|
|
2050
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2051
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jwt_claims_set.js
|
|
2051
2052
|
const epoch = (date) => Math.floor(date.getTime() / 1e3);
|
|
2052
2053
|
const multipliers = {
|
|
2053
2054
|
s: 1,
|
|
@@ -2144,7 +2145,7 @@ function jwtClaimsSetBytes(producer) {
|
|
|
2144
2145
|
const value = claimsSet[claim];
|
|
2145
2146
|
if (typeof value == "number" && !Number.isFinite(value)) throw new TypeError(`"${claim}" claim must be a finite number`);
|
|
2146
2147
|
}
|
|
2147
|
-
return encoder$
|
|
2148
|
+
return encoder$3.encode(JSON.stringify(claimsSet));
|
|
2148
2149
|
}
|
|
2149
2150
|
var JWTClaimsBuilder = class {
|
|
2150
2151
|
constructor(claimsSet = {}) {
|
|
@@ -2175,7 +2176,7 @@ var JWTClaimsBuilder = class {
|
|
|
2175
2176
|
}
|
|
2176
2177
|
};
|
|
2177
2178
|
//#endregion
|
|
2178
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2179
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwt/verify.js
|
|
2179
2180
|
async function jwtVerify(jwt, key, options) {
|
|
2180
2181
|
const [verified, b64] = await verifyCompact(jwt, prepareVerify(options), key);
|
|
2181
2182
|
if (!b64) throw new JWTInvalid("JWTs MUST NOT use unencoded payload");
|
|
@@ -2186,7 +2187,7 @@ async function jwtVerify(jwt, key, options) {
|
|
|
2186
2187
|
};
|
|
2187
2188
|
}
|
|
2188
2189
|
//#endregion
|
|
2189
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2190
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwe/compact/encrypt.js
|
|
2190
2191
|
var CompactEncrypt = class {
|
|
2191
2192
|
#input;
|
|
2192
2193
|
constructor(plaintext) {
|
|
@@ -2209,7 +2210,7 @@ var CompactEncrypt = class {
|
|
|
2209
2210
|
}
|
|
2210
2211
|
};
|
|
2211
2212
|
//#endregion
|
|
2212
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2213
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/jws_sign.js
|
|
2213
2214
|
async function createSignature(input, key, rejectUnencoded) {
|
|
2214
2215
|
let [payload, protectedHeader, unprotectedHeader, crit] = input, encodedProtectedHeader = "";
|
|
2215
2216
|
if (protectedHeader !== void 0) {
|
|
@@ -2231,7 +2232,7 @@ async function createSignature(input, key, rejectUnencoded) {
|
|
|
2231
2232
|
let encodedPayload = "", signingPayload = payload, signingInput;
|
|
2232
2233
|
if (b64) {
|
|
2233
2234
|
const encodedPayloadCache = input[4];
|
|
2234
|
-
encodedPayloadCache ? (encodedPayload = encodedPayloadCache[0] ??= encode$1(payload), signingPayload = encodedPayloadCache[1] ??= encode$2(encodedPayload)) : (encodedPayload = encode$1(payload), signingInput = encoder$
|
|
2235
|
+
encodedPayloadCache ? (encodedPayload = encodedPayloadCache[0] ??= encode$1(payload), signingPayload = encodedPayloadCache[1] ??= encode$2(encodedPayload)) : (encodedPayload = encode$1(payload), signingInput = encoder$3.encode(`${encodedProtectedHeader}.${encodedPayload}`));
|
|
2235
2236
|
}
|
|
2236
2237
|
signingInput ??= concat(encode$2(encodedProtectedHeader), encode$2("."), signingPayload);
|
|
2237
2238
|
const signingKey = await rawKey(await prepareKey(entry, key, "sign"), entry.subtle, "sign");
|
|
@@ -2252,7 +2253,7 @@ async function createCompactSignature(payload, protectedHeader, crit, key, rejec
|
|
|
2252
2253
|
return `${jws.protected}.${jws.payload}.${jws.signature}`;
|
|
2253
2254
|
}
|
|
2254
2255
|
//#endregion
|
|
2255
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2256
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jws/compact/sign.js
|
|
2256
2257
|
var CompactSign = class {
|
|
2257
2258
|
#payload;
|
|
2258
2259
|
#protectedHeader;
|
|
@@ -2269,7 +2270,7 @@ var CompactSign = class {
|
|
|
2269
2270
|
}
|
|
2270
2271
|
};
|
|
2271
2272
|
//#endregion
|
|
2272
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2273
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/lib/key_algorithm.js
|
|
2273
2274
|
function unsupportedAlg(source = "JWK \"alg\" (Algorithm) Parameter") {
|
|
2274
2275
|
throw new JOSENotSupported(`Invalid or unsupported ${source} value`);
|
|
2275
2276
|
}
|
|
@@ -2277,7 +2278,7 @@ function keyAlgorithm(alg, source) {
|
|
|
2277
2278
|
return (typeof alg == "string" ? JWS[alg] ?? JWE[alg] : void 0) ?? unsupportedAlg(source);
|
|
2278
2279
|
}
|
|
2279
2280
|
//#endregion
|
|
2280
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2281
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/key/export.js
|
|
2281
2282
|
async function exportJWK(key) {
|
|
2282
2283
|
if (isKeyObject(key)) if (key.type === "secret") key = key.export();
|
|
2283
2284
|
else return key.export({ format: "jwk" });
|
|
@@ -2293,7 +2294,7 @@ async function exportJWK(key) {
|
|
|
2293
2294
|
return jwk;
|
|
2294
2295
|
}
|
|
2295
2296
|
//#endregion
|
|
2296
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2297
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwk/thumbprint.js
|
|
2297
2298
|
const check$2 = (value, description) => {
|
|
2298
2299
|
if (typeof value != "string" || !value) throw new JWKInvalid(`${description} missing or invalid`);
|
|
2299
2300
|
};
|
|
@@ -2348,7 +2349,7 @@ async function calculateJwkThumbprint(key, digestAlgorithm) {
|
|
|
2348
2349
|
return encode$1(await digest(digestAlgorithm, thumbprintInput));
|
|
2349
2350
|
}
|
|
2350
2351
|
//#endregion
|
|
2351
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2352
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwk/embedded.js
|
|
2352
2353
|
async function EmbeddedJWK(protectedHeader, token) {
|
|
2353
2354
|
const joseHeader = {
|
|
2354
2355
|
...protectedHeader,
|
|
@@ -2369,7 +2370,7 @@ async function EmbeddedJWK(protectedHeader, token) {
|
|
|
2369
2370
|
return key;
|
|
2370
2371
|
}
|
|
2371
2372
|
//#endregion
|
|
2372
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2373
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/jwt/unsecured.js
|
|
2373
2374
|
const UnsecuredJWT_base = JWTClaimsBuilder;
|
|
2374
2375
|
var UnsecuredJWT = class extends UnsecuredJWT_base {
|
|
2375
2376
|
encode() {
|
|
@@ -2396,7 +2397,7 @@ var UnsecuredJWT = class extends UnsecuredJWT_base {
|
|
|
2396
2397
|
}
|
|
2397
2398
|
};
|
|
2398
2399
|
//#endregion
|
|
2399
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2400
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/key/import.js
|
|
2400
2401
|
async function importJWK(jwk, alg, options) {
|
|
2401
2402
|
if (!isObject(jwk)) throw new TypeError("JWK must be an object");
|
|
2402
2403
|
const normalized = normalizeJwk(jwk), extractable = validateExtractableOption(options?.extractable), { alg: jwkAlg } = normalized;
|
|
@@ -2416,7 +2417,7 @@ async function importJWK(jwk, alg, options) {
|
|
|
2416
2417
|
}
|
|
2417
2418
|
}
|
|
2418
2419
|
//#endregion
|
|
2419
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2420
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/util/decode_protected_header.js
|
|
2420
2421
|
function decodeProtectedHeader(token) {
|
|
2421
2422
|
let encodedProtectedHeader;
|
|
2422
2423
|
if (typeof token == "string") {
|
|
@@ -2429,7 +2430,7 @@ function decodeProtectedHeader(token) {
|
|
|
2429
2430
|
return parseJoseHeader(encodedProtectedHeader, TypeError, invalid);
|
|
2430
2431
|
}
|
|
2431
2432
|
//#endregion
|
|
2432
|
-
//#region node_modules/.pnpm/jose@6.2.
|
|
2433
|
+
//#region node_modules/.pnpm/jose@6.2.14/node_modules/jose/dist/webapi/util/decode_jwt.js
|
|
2433
2434
|
function decodeJwt(jwt) {
|
|
2434
2435
|
if (typeof jwt != "string") throw new JWTInvalid("JWTs must use Compact JWS serialization, JWT must be a string");
|
|
2435
2436
|
const { 1: encodedPayload, length } = jwt.split(".");
|
|
@@ -2715,308 +2716,6 @@ const renderConsentPage = (view) => {
|
|
|
2715
2716
|
/** Plain error page for a failed sign-in. ASCII message, escaped. */
|
|
2716
2717
|
const renderErrorPage = (title, detail) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sign-in failed</title></head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p></body></html>`;
|
|
2717
2718
|
//#endregion
|
|
2718
|
-
//#region src/auth/server/upstream.ts
|
|
2719
|
-
/**
|
|
2720
|
-
* Zendesk as the upstream identity provider, through the same public PKCE client
|
|
2721
|
-
* the stdio flow uses. The server runs PKCE itself; no client secret exists.
|
|
2722
|
-
* Why: `docs/decisions/oauth-authorization-server.md` ("Upstream login").
|
|
2723
|
-
*/
|
|
2724
|
-
const ZENDESK_ACCESS_TOKEN_TTL_S = 172800;
|
|
2725
|
-
const ZENDESK_REFRESH_TOKEN_TTL_S = 7776e3;
|
|
2726
|
-
/** ASCII-only (auth paths end up in headers); never carries the Zendesk body. */
|
|
2727
|
-
const upstreamAuthError = (message, status, cause) => Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), {
|
|
2728
|
-
name: "UpstreamAuthError",
|
|
2729
|
-
...status === void 0 ? {} : { status }
|
|
2730
|
-
});
|
|
2731
|
-
const isUpstreamAuthError = (err) => err instanceof Error && err.name === "UpstreamAuthError";
|
|
2732
|
-
const buildZendeskAuthorizeUrl = (upstream, params) => {
|
|
2733
|
-
const url = new URL(getOAuthUrls(upstream.subdomain).authorizeUrl);
|
|
2734
|
-
url.searchParams.set("response_type", "code");
|
|
2735
|
-
url.searchParams.set("client_id", upstream.clientId);
|
|
2736
|
-
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
2737
|
-
url.searchParams.set("scope", params.scope);
|
|
2738
|
-
url.searchParams.set("state", params.state);
|
|
2739
|
-
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
2740
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
2741
|
-
return url.toString();
|
|
2742
|
-
};
|
|
2743
|
-
const toTokenSet = (result, previousRefreshToken) => ({
|
|
2744
|
-
accessToken: result.access_token,
|
|
2745
|
-
refreshToken: result.refresh_token ?? previousRefreshToken,
|
|
2746
|
-
expiresAt: result.expires_in === void 0 ? void 0 : Date.now() + result.expires_in * 1e3,
|
|
2747
|
-
scope: result.scope
|
|
2748
|
-
});
|
|
2749
|
-
const requestTokens = async (upstream, grant, logger) => {
|
|
2750
|
-
const body = new URLSearchParams({
|
|
2751
|
-
client_id: upstream.clientId,
|
|
2752
|
-
expires_in: String(ZENDESK_ACCESS_TOKEN_TTL_S),
|
|
2753
|
-
refresh_token_expires_in: String(ZENDESK_REFRESH_TOKEN_TTL_S),
|
|
2754
|
-
...grant
|
|
2755
|
-
});
|
|
2756
|
-
let response;
|
|
2757
|
-
try {
|
|
2758
|
-
response = await fetch(getOAuthUrls(upstream.subdomain).tokenUrl, {
|
|
2759
|
-
method: "POST",
|
|
2760
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2761
|
-
body: body.toString(),
|
|
2762
|
-
signal: deadlineSignal(upstream.timeoutMs ?? 3e4)
|
|
2763
|
-
});
|
|
2764
|
-
} catch (err) {
|
|
2765
|
-
throw upstreamAuthError("Zendesk token endpoint unreachable.", void 0, err);
|
|
2766
|
-
}
|
|
2767
|
-
logger.debug("oauth_upstream_token", {
|
|
2768
|
-
grant: grant["grant_type"],
|
|
2769
|
-
status: response.status
|
|
2770
|
-
});
|
|
2771
|
-
if (!response.ok) throw upstreamAuthError(`Zendesk refused the ${grant["grant_type"]} grant (${response.status}).`, response.status);
|
|
2772
|
-
return await response.json();
|
|
2773
|
-
};
|
|
2774
|
-
const exchangeZendeskCode = async (upstream, params, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
|
|
2775
|
-
grant_type: "authorization_code",
|
|
2776
|
-
code: params.code,
|
|
2777
|
-
redirect_uri: params.redirectUri,
|
|
2778
|
-
code_verifier: params.codeVerifier
|
|
2779
|
-
}, logger));
|
|
2780
|
-
/** Zendesk rotates on refresh: the caller must persist the returned set. */
|
|
2781
|
-
const refreshZendeskTokens = async (upstream, refreshToken, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
|
|
2782
|
-
grant_type: "refresh_token",
|
|
2783
|
-
refresh_token: refreshToken
|
|
2784
|
-
}, logger), refreshToken);
|
|
2785
|
-
/** Zendesk is not OIDC: the account is whoever `/users/me` says the token belongs to. */
|
|
2786
|
-
const fetchZendeskIdentity = async (upstream, accessToken) => {
|
|
2787
|
-
const failure = (cause) => upstreamAuthError("Could not resolve the Zendesk account of the signed-in user.", void 0, cause);
|
|
2788
|
-
let user;
|
|
2789
|
-
try {
|
|
2790
|
-
({user} = await zendeskGet(upstream.subdomain, accessToken, "/users/me"));
|
|
2791
|
-
} catch (err) {
|
|
2792
|
-
throw failure(err);
|
|
2793
|
-
}
|
|
2794
|
-
if (typeof user?.id !== "number") throw failure();
|
|
2795
|
-
return {
|
|
2796
|
-
id: user.id,
|
|
2797
|
-
name: user.name,
|
|
2798
|
-
role: user.role
|
|
2799
|
-
};
|
|
2800
|
-
};
|
|
2801
|
-
//#endregion
|
|
2802
|
-
//#region src/auth/server/interactions.ts
|
|
2803
|
-
const PENDING_TTL_MS = 6e5;
|
|
2804
|
-
const CONSENT_MEMORY_TTL_S = 7776e3;
|
|
2805
|
-
const sendHtml = (res, status, html) => {
|
|
2806
|
-
res.writeHead(status, {
|
|
2807
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
2808
|
-
"Cache-Control": "no-store"
|
|
2809
|
-
});
|
|
2810
|
-
res.end(html);
|
|
2811
|
-
};
|
|
2812
|
-
const redirect = (res, location) => {
|
|
2813
|
-
res.writeHead(302, {
|
|
2814
|
-
Location: location,
|
|
2815
|
-
"Cache-Control": "no-store"
|
|
2816
|
-
});
|
|
2817
|
-
res.end();
|
|
2818
|
-
};
|
|
2819
|
-
const EXPIRED = "This sign-in has expired or was opened in another browser. Start the connection again from your MCP client.";
|
|
2820
|
-
const consentMemoryKey = (accountId, clientId, redirectUris) => createHash("sha256").update(JSON.stringify([
|
|
2821
|
-
accountId,
|
|
2822
|
-
clientId,
|
|
2823
|
-
[...redirectUris].sort(compareCodeUnits)
|
|
2824
|
-
])).digest("base64url");
|
|
2825
|
-
const isExpiredLink = (err) => err instanceof Error && (err.name === "SessionNotFound" || err.name === "InvalidRequest");
|
|
2826
|
-
const UID = /^[\w-]{1,64}$/;
|
|
2827
|
-
/**
|
|
2828
|
-
* The login and consent steps oidc-provider hands off to us. Login is always a
|
|
2829
|
-
* round trip to Zendesk (fresh tokens for every grant); consent is skipped for
|
|
2830
|
-
* trusted CIMD clients and for a client this user already approved with the
|
|
2831
|
-
* same redirect URIs, and shown otherwise (ADR, Consent).
|
|
2832
|
-
*/
|
|
2833
|
-
const createInteractionRoutes = (deps) => {
|
|
2834
|
-
const { provider, upstream, issuer } = deps;
|
|
2835
|
-
const logger = deps.logger ?? silentLogger;
|
|
2836
|
-
const callbackUrl = `${issuer}/oauth/callback`;
|
|
2837
|
-
const verifiers = createExpiringMap();
|
|
2838
|
-
const upstreamTokens = createExpiringMap();
|
|
2839
|
-
const finish = (req, res, result) => provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
|
|
2840
|
-
const deny = (req, res, description) => finish(req, res, {
|
|
2841
|
-
error: "access_denied",
|
|
2842
|
-
error_description: description
|
|
2843
|
-
});
|
|
2844
|
-
const startUpstreamLogin = (res, uid) => {
|
|
2845
|
-
const verifier = generateCodeVerifier();
|
|
2846
|
-
verifiers.set(uid, verifier, PENDING_TTL_MS);
|
|
2847
|
-
redirect(res, buildZendeskAuthorizeUrl(upstream, {
|
|
2848
|
-
redirectUri: callbackUrl,
|
|
2849
|
-
scope: requestedScope(deps.readOnly),
|
|
2850
|
-
state: uid,
|
|
2851
|
-
codeChallenge: generateCodeChallenge(verifier)
|
|
2852
|
-
}));
|
|
2853
|
-
};
|
|
2854
|
-
const approve = async (req, res, details, { accountId, clientId }, tokens) => {
|
|
2855
|
-
const grant = new provider.Grant({
|
|
2856
|
-
accountId,
|
|
2857
|
-
clientId
|
|
2858
|
-
});
|
|
2859
|
-
const missing = details.prompt.details;
|
|
2860
|
-
if (missing.missingOIDCScope?.length) grant.addOIDCScope(missing.missingOIDCScope.join(" "));
|
|
2861
|
-
for (const [indicator, scopes] of Object.entries(missing.missingResourceScopes ?? {})) grant.addResourceScope(indicator, scopes.join(" "));
|
|
2862
|
-
const grantId = await grant.save();
|
|
2863
|
-
await deps.zendeskGrants.save(grantId, tokens);
|
|
2864
|
-
logger.info("oauth_grant_created", { clientId });
|
|
2865
|
-
await finish(req, res, { consent: { grantId } });
|
|
2866
|
-
};
|
|
2867
|
-
const consentContext = async (details) => {
|
|
2868
|
-
const accountId = String(details.session?.accountId);
|
|
2869
|
-
const clientId = String(details.params["client_id"]);
|
|
2870
|
-
const client = await provider.Client.find(clientId);
|
|
2871
|
-
const redirectUris = client?.redirectUris ?? [];
|
|
2872
|
-
return {
|
|
2873
|
-
client,
|
|
2874
|
-
accountId,
|
|
2875
|
-
clientId,
|
|
2876
|
-
pendingKey: `${accountId}|${clientId}`,
|
|
2877
|
-
memoryKey: consentMemoryKey(accountId, clientId, redirectUris),
|
|
2878
|
-
redirectUri: String(details.params["redirect_uri"]),
|
|
2879
|
-
redirectUris
|
|
2880
|
-
};
|
|
2881
|
-
};
|
|
2882
|
-
const showInteraction = async (req, res, uid) => {
|
|
2883
|
-
const details = await provider.interactionDetails(req, res);
|
|
2884
|
-
if (details.prompt.name === "login") {
|
|
2885
|
-
startUpstreamLogin(res, uid);
|
|
2886
|
-
return;
|
|
2887
|
-
}
|
|
2888
|
-
const context = await consentContext(details);
|
|
2889
|
-
if (canSkipConsent({
|
|
2890
|
-
clientId: context.clientId,
|
|
2891
|
-
redirectUri: context.redirectUri,
|
|
2892
|
-
registeredRedirectUris: context.redirectUris
|
|
2893
|
-
}, deps.trustedClients) || await deps.consentMemory.get(context.memoryKey) === true) {
|
|
2894
|
-
const tokens = upstreamTokens.take(context.pendingKey);
|
|
2895
|
-
if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
|
|
2896
|
-
return approve(req, res, details, context, tokens);
|
|
2897
|
-
}
|
|
2898
|
-
const missing = details.prompt.details;
|
|
2899
|
-
const scopes = [...new Set(Object.values(missing.missingResourceScopes ?? {}).flat())];
|
|
2900
|
-
sendHtml(res, 200, renderConsentPage({
|
|
2901
|
-
uid,
|
|
2902
|
-
clientName: context.client?.clientName,
|
|
2903
|
-
clientId: context.clientId,
|
|
2904
|
-
redirectUri: context.redirectUri,
|
|
2905
|
-
scopes,
|
|
2906
|
-
subdomain: upstream.subdomain
|
|
2907
|
-
}));
|
|
2908
|
-
};
|
|
2909
|
-
const completeUpstreamLogin = async (req, res, uid, url) => {
|
|
2910
|
-
const details = await provider.interactionDetails(req, res);
|
|
2911
|
-
const verifier = verifiers.take(uid);
|
|
2912
|
-
const code = url.searchParams.get("code");
|
|
2913
|
-
if (!verifier || !code) return deny(req, res, "The Zendesk sign-in was cancelled or refused.");
|
|
2914
|
-
try {
|
|
2915
|
-
const tokens = await exchangeZendeskCode(upstream, {
|
|
2916
|
-
code,
|
|
2917
|
-
redirectUri: callbackUrl,
|
|
2918
|
-
codeVerifier: verifier
|
|
2919
|
-
}, logger);
|
|
2920
|
-
const accountId = `zendesk:${(await fetchZendeskIdentity(upstream, tokens.accessToken)).id}`;
|
|
2921
|
-
upstreamTokens.set(`${accountId}|${String(details.params["client_id"])}`, tokens, PENDING_TTL_MS);
|
|
2922
|
-
await finish(req, res, { login: { accountId } });
|
|
2923
|
-
} catch (err) {
|
|
2924
|
-
if (!isUpstreamAuthError(err)) throw err;
|
|
2925
|
-
logger.warn("oauth_upstream_login_failed", { error: err.message });
|
|
2926
|
-
await deny(req, res, err.message);
|
|
2927
|
-
}
|
|
2928
|
-
};
|
|
2929
|
-
const confirm = async (req, res) => {
|
|
2930
|
-
const details = await provider.interactionDetails(req, res);
|
|
2931
|
-
if (details.prompt.name !== "consent") return sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
2932
|
-
const context = await consentContext(details);
|
|
2933
|
-
const tokens = upstreamTokens.take(context.pendingKey);
|
|
2934
|
-
if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
|
|
2935
|
-
await deps.consentMemory.set(context.memoryKey, true, CONSENT_MEMORY_TTL_S);
|
|
2936
|
-
return approve(req, res, details, context, tokens);
|
|
2937
|
-
};
|
|
2938
|
-
const routes = async (req, res, url) => {
|
|
2939
|
-
const [, , uid = "", action] = url.pathname.split("/");
|
|
2940
|
-
if (!UID.test(uid)) return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
|
|
2941
|
-
if (req.method === "GET" && action === void 0) return showInteraction(req, res, uid);
|
|
2942
|
-
if (req.method === "GET" && action === "callback") return completeUpstreamLogin(req, res, uid, url);
|
|
2943
|
-
if (req.method === "POST" && action === "confirm") return confirm(req, res);
|
|
2944
|
-
if (req.method === "POST" && action === "abort") {
|
|
2945
|
-
await provider.interactionDetails(req, res);
|
|
2946
|
-
return deny(req, res, "The user declined access.");
|
|
2947
|
-
}
|
|
2948
|
-
return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
|
|
2949
|
-
};
|
|
2950
|
-
return {
|
|
2951
|
-
/** `/oauth/callback`: the one redirect URI registered at Zendesk. */
|
|
2952
|
-
upstreamCallback: (res, url) => {
|
|
2953
|
-
const uid = url.searchParams.get("state") ?? "";
|
|
2954
|
-
if (!UID.test(uid)) {
|
|
2955
|
-
sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
2956
|
-
return;
|
|
2957
|
-
}
|
|
2958
|
-
const next = new URL(`/interaction/${uid}/callback`, issuer);
|
|
2959
|
-
const code = url.searchParams.get("code");
|
|
2960
|
-
if (code) next.searchParams.set("code", code);
|
|
2961
|
-
redirect(res, `${next.pathname}${next.search}`);
|
|
2962
|
-
},
|
|
2963
|
-
/** `/interaction/<uid>[/callback|/confirm|/abort]`. */
|
|
2964
|
-
handle: async (req, res, url) => {
|
|
2965
|
-
try {
|
|
2966
|
-
await routes(req, res, url);
|
|
2967
|
-
} catch (err) {
|
|
2968
|
-
if (!isExpiredLink(err)) throw err;
|
|
2969
|
-
if (!res.headersSent) sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
};
|
|
2973
|
-
};
|
|
2974
|
-
//#endregion
|
|
2975
|
-
//#region src/auth/server/keys.ts
|
|
2976
|
-
const MIN_SECRET_BYTES = 32;
|
|
2977
|
-
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
2978
|
-
const derive = (master, purpose, length = 32) => Buffer.from(hkdfSync("sha256", master, Buffer.alloc(0), `zendesk-mcp/${purpose}/v1`, length));
|
|
2979
|
-
const kidFor = (master, purpose) => createHash("sha256").update(derive(master, `kid/${purpose}`, 16)).digest("base64url").slice(0, 16);
|
|
2980
|
-
/**
|
|
2981
|
-
* Parse `OAUTH_MASTER_SECRET`: base64 entries, comma-separated, newest first.
|
|
2982
|
-
* Errors never echo the value.
|
|
2983
|
-
*/
|
|
2984
|
-
const parseSecretList = (raw) => {
|
|
2985
|
-
const entries = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((entry) => Buffer.from(entry, "base64"));
|
|
2986
|
-
const [first, ...rest] = entries;
|
|
2987
|
-
if (!first) throw new Error("OAuth master secret is empty.");
|
|
2988
|
-
if (entries.some((secret) => secret.length < 32)) throw new Error(`OAuth master secret must decode to at least 32 bytes of base64 (generate one with: openssl rand -base64 32).`);
|
|
2989
|
-
return [first, ...rest];
|
|
2990
|
-
};
|
|
2991
|
-
const deriveKeySet = (master) => {
|
|
2992
|
-
return {
|
|
2993
|
-
signingJwk: {
|
|
2994
|
-
...createPrivateKey({
|
|
2995
|
-
key: Buffer.concat([ED25519_PKCS8_PREFIX, derive(master, "signing")]),
|
|
2996
|
-
format: "der",
|
|
2997
|
-
type: "pkcs8"
|
|
2998
|
-
}).export({ format: "jwk" }),
|
|
2999
|
-
kid: kidFor(master, "signing"),
|
|
3000
|
-
use: "sig",
|
|
3001
|
-
alg: "EdDSA"
|
|
3002
|
-
},
|
|
3003
|
-
accessToken: {
|
|
3004
|
-
kid: kidFor(master, "access-token"),
|
|
3005
|
-
key: createSecretKey(derive(master, "access-token"))
|
|
3006
|
-
},
|
|
3007
|
-
atRest: {
|
|
3008
|
-
kid: kidFor(master, "at-rest"),
|
|
3009
|
-
key: createSecretKey(derive(master, "at-rest"))
|
|
3010
|
-
},
|
|
3011
|
-
cookieKey: derive(master, "cookie").toString("base64url"),
|
|
3012
|
-
requestState: derive(master, "request-state")
|
|
3013
|
-
};
|
|
3014
|
-
};
|
|
3015
|
-
const deriveKeyRing = (raw) => {
|
|
3016
|
-
const [first, ...rest] = parseSecretList(raw);
|
|
3017
|
-
return [deriveKeySet(first), ...rest.map(deriveKeySet)];
|
|
3018
|
-
};
|
|
3019
|
-
//#endregion
|
|
3020
2719
|
//#region node_modules/.pnpm/oidc-provider@9.13.0_supports-color@7.2.0/node_modules/oidc-provider/lib/helpers/attention.js
|
|
3021
2720
|
const SET_BOLD_YELLOW_TEXT = "\x1B[33;1m";
|
|
3022
2721
|
const SET_BOLD_RED_TEXT = "\x1B[31;1m";
|
|
@@ -29313,7 +29012,7 @@ function input(action, csrfToken, code, charset) {
|
|
|
29313
29012
|
${code ? `value="${htmlSafe(code)}" ` : ""}${attributes}type="text" name="user_code" placeholder="Enter code" onfocus="this.select(); this.onfocus = undefined;" autofocus autocomplete="off"></input>
|
|
29314
29013
|
</form>`;
|
|
29315
29014
|
}
|
|
29316
|
-
function confirm$
|
|
29015
|
+
function confirm$2(action, csrfToken, code) {
|
|
29317
29016
|
return `<form id="op.deviceConfirmForm" method="post" action="${action}">
|
|
29318
29017
|
<input type="hidden" name="xsrf" value="${csrfToken}"/>
|
|
29319
29018
|
<input type="hidden" name="user_code" value="${htmlSafe(code)}"/>
|
|
@@ -32480,7 +32179,7 @@ const init = [
|
|
|
32480
32179
|
});
|
|
32481
32180
|
}
|
|
32482
32181
|
];
|
|
32483
|
-
const confirm = [
|
|
32182
|
+
const confirm$1 = [
|
|
32484
32183
|
noCache,
|
|
32485
32184
|
sessionHandler,
|
|
32486
32185
|
urlencoded,
|
|
@@ -32597,7 +32296,7 @@ const post = [
|
|
|
32597
32296
|
const client = await ctx.oidc.provider.Client.find(code.clientId);
|
|
32598
32297
|
if (!client) throw new InvalidClient("client is invalid", "client not found");
|
|
32599
32298
|
ctx.oidc.entity("Client", client);
|
|
32600
|
-
await userCodeConfirmSource(ctx, confirm$
|
|
32299
|
+
await userCodeConfirmSource(ctx, confirm$2(ctx.oidc.urlFor("code_verification"), ctx.oidc.session.state.secret, userCode), client, code.deviceInfo, denormalize(normalized, mask));
|
|
32601
32300
|
return;
|
|
32602
32301
|
}
|
|
32603
32302
|
code.inFlight = true;
|
|
@@ -33230,7 +32929,7 @@ function initializeApp() {
|
|
|
33230
32929
|
post$2("introspection", routes.introspection, getErrorHandler("introspection.error"), CORS.client, ...introspection_default);
|
|
33231
32930
|
options("cors.introspection", routes.introspection, CORS.client, CORS.respond);
|
|
33232
32931
|
}
|
|
33233
|
-
post$2("end_session_confirm", `${routes.end_session}/confirm`, getErrorHandler("end_session_confirm.error"), ...confirm);
|
|
32932
|
+
post$2("end_session_confirm", `${routes.end_session}/confirm`, getErrorHandler("end_session_confirm.error"), ...confirm$1);
|
|
33234
32933
|
if (features.rpInitiatedLogout.enabled) {
|
|
33235
32934
|
if (enableHttpPostMethods) post$2("end_session", routes.end_session, getErrorHandler("end_session.error"), ...init);
|
|
33236
32935
|
get$3("end_session", routes.end_session, getErrorHandler("end_session.error"), ...init);
|
|
@@ -36059,10 +35758,10 @@ const PERSISTENT_MODELS = /* @__PURE__ */ new Set([
|
|
|
36059
35758
|
"RefreshToken",
|
|
36060
35759
|
"ZendeskTokens"
|
|
36061
35760
|
]);
|
|
36062
|
-
const encoder = new TextEncoder();
|
|
35761
|
+
const encoder$2 = new TextEncoder();
|
|
36063
35762
|
const decoder$1 = new TextDecoder();
|
|
36064
35763
|
const hashId = (id) => createHash("sha256").update(id).digest("base64url");
|
|
36065
|
-
const seal = (ring, value, expiresAt) => new CompactEncrypt(encoder.encode(JSON.stringify(value))).setProtectedHeader({
|
|
35764
|
+
const seal = (ring, value, expiresAt) => new CompactEncrypt(encoder$2.encode(JSON.stringify(value))).setProtectedHeader({
|
|
36066
35765
|
alg: "dir",
|
|
36067
35766
|
enc: "A256GCM",
|
|
36068
35767
|
kid: ring[0].atRest.kid,
|
|
@@ -36079,7 +35778,7 @@ const unseal = async (ring, sealed) => {
|
|
|
36079
35778
|
expiresAt
|
|
36080
35779
|
};
|
|
36081
35780
|
};
|
|
36082
|
-
const open = (ring, sealed) => unseal(ring, sealed).catch(() => void 0);
|
|
35781
|
+
const open$1 = (ring, sealed) => unseal(ring, sealed).catch(() => void 0);
|
|
36083
35782
|
const secondsUntil = (exp) => exp === void 0 ? void 0 : Math.max(exp - Math.floor(Date.now() / 1e3), 1);
|
|
36084
35783
|
/**
|
|
36085
35784
|
* One named collection of whole-payload-encrypted records. A record read under
|
|
@@ -36092,7 +35791,7 @@ const createSealedCollection = (store, name, ring) => {
|
|
|
36092
35791
|
get: async (id) => {
|
|
36093
35792
|
const sealed = await store.get(key(id));
|
|
36094
35793
|
if (sealed === void 0) return void 0;
|
|
36095
|
-
const record = await open(ring, sealed);
|
|
35794
|
+
const record = await open$1(ring, sealed);
|
|
36096
35795
|
if (record && !record.current) {
|
|
36097
35796
|
const { expiresAt } = record;
|
|
36098
35797
|
const ttlMs = expiresAt === void 0 ? void 0 : expiresAt - Date.now();
|
|
@@ -36182,6 +35881,92 @@ const openStore = (uri) => {
|
|
|
36182
35881
|
throw new Error(`Unsupported OAuth store scheme "${protocol}". Use file:// or memory://.`);
|
|
36183
35882
|
};
|
|
36184
35883
|
//#endregion
|
|
35884
|
+
//#region src/auth/server/upstream.ts
|
|
35885
|
+
/**
|
|
35886
|
+
* Zendesk as the upstream identity provider, through the same public PKCE client
|
|
35887
|
+
* the stdio flow uses. The server runs PKCE itself; no client secret exists.
|
|
35888
|
+
* Why: `docs/decisions/oauth-authorization-server.md` ("Upstream login").
|
|
35889
|
+
*/
|
|
35890
|
+
const ZENDESK_ACCESS_TOKEN_TTL_S = 172800;
|
|
35891
|
+
const ZENDESK_REFRESH_TOKEN_TTL_S = 7776e3;
|
|
35892
|
+
/** The Zendesk role that opens the `/mcp/admin` back-office. */
|
|
35893
|
+
const ZENDESK_ADMIN_ROLE = "admin";
|
|
35894
|
+
/** ASCII-only (auth paths end up in headers); never carries the Zendesk body. */
|
|
35895
|
+
const upstreamAuthError = (message, status, cause) => Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), {
|
|
35896
|
+
name: "UpstreamAuthError",
|
|
35897
|
+
...status === void 0 ? {} : { status }
|
|
35898
|
+
});
|
|
35899
|
+
const isUpstreamAuthError = (err) => err instanceof Error && err.name === "UpstreamAuthError";
|
|
35900
|
+
const buildZendeskAuthorizeUrl = (upstream, params) => {
|
|
35901
|
+
const url = new URL(getOAuthUrls(upstream.subdomain).authorizeUrl);
|
|
35902
|
+
url.searchParams.set("response_type", "code");
|
|
35903
|
+
url.searchParams.set("client_id", upstream.clientId);
|
|
35904
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
35905
|
+
url.searchParams.set("scope", params.scope);
|
|
35906
|
+
url.searchParams.set("state", params.state);
|
|
35907
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
35908
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
35909
|
+
return url.toString();
|
|
35910
|
+
};
|
|
35911
|
+
const toTokenSet = (result, previousRefreshToken) => ({
|
|
35912
|
+
accessToken: result.access_token,
|
|
35913
|
+
refreshToken: result.refresh_token ?? previousRefreshToken,
|
|
35914
|
+
expiresAt: result.expires_in === void 0 ? void 0 : Date.now() + result.expires_in * 1e3,
|
|
35915
|
+
scope: result.scope
|
|
35916
|
+
});
|
|
35917
|
+
const requestTokens = async (upstream, grant, logger) => {
|
|
35918
|
+
const body = new URLSearchParams({
|
|
35919
|
+
client_id: upstream.clientId,
|
|
35920
|
+
expires_in: String(ZENDESK_ACCESS_TOKEN_TTL_S),
|
|
35921
|
+
refresh_token_expires_in: String(ZENDESK_REFRESH_TOKEN_TTL_S),
|
|
35922
|
+
...grant
|
|
35923
|
+
});
|
|
35924
|
+
let response;
|
|
35925
|
+
try {
|
|
35926
|
+
response = await fetch(getOAuthUrls(upstream.subdomain).tokenUrl, {
|
|
35927
|
+
method: "POST",
|
|
35928
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
35929
|
+
body: body.toString(),
|
|
35930
|
+
signal: deadlineSignal(upstream.timeoutMs ?? 3e4)
|
|
35931
|
+
});
|
|
35932
|
+
} catch (err) {
|
|
35933
|
+
throw upstreamAuthError("Zendesk token endpoint unreachable.", void 0, err);
|
|
35934
|
+
}
|
|
35935
|
+
logger.debug("oauth_upstream_token", {
|
|
35936
|
+
grant: grant["grant_type"],
|
|
35937
|
+
status: response.status
|
|
35938
|
+
});
|
|
35939
|
+
if (!response.ok) throw upstreamAuthError(`Zendesk refused the ${grant["grant_type"]} grant (${response.status}).`, response.status);
|
|
35940
|
+
return await response.json();
|
|
35941
|
+
};
|
|
35942
|
+
const exchangeZendeskCode = async (upstream, params, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
|
|
35943
|
+
grant_type: "authorization_code",
|
|
35944
|
+
code: params.code,
|
|
35945
|
+
redirect_uri: params.redirectUri,
|
|
35946
|
+
code_verifier: params.codeVerifier
|
|
35947
|
+
}, logger));
|
|
35948
|
+
/** Zendesk rotates on refresh: the caller must persist the returned set. */
|
|
35949
|
+
const refreshZendeskTokens = async (upstream, refreshToken, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
|
|
35950
|
+
grant_type: "refresh_token",
|
|
35951
|
+
refresh_token: refreshToken
|
|
35952
|
+
}, logger), refreshToken);
|
|
35953
|
+
/** Zendesk is not OIDC: the account is whoever `/users/me` says the token belongs to. */
|
|
35954
|
+
const fetchZendeskIdentity = async (upstream, accessToken) => {
|
|
35955
|
+
const failure = (cause) => upstreamAuthError("Could not resolve the Zendesk account of the signed-in user.", void 0, cause);
|
|
35956
|
+
let user;
|
|
35957
|
+
try {
|
|
35958
|
+
({user} = await zendeskGet(upstream.subdomain, accessToken, "/users/me"));
|
|
35959
|
+
} catch (err) {
|
|
35960
|
+
throw failure(err);
|
|
35961
|
+
}
|
|
35962
|
+
if (typeof user?.id !== "number") throw failure();
|
|
35963
|
+
return {
|
|
35964
|
+
id: user.id,
|
|
35965
|
+
name: user.name,
|
|
35966
|
+
role: user.role
|
|
35967
|
+
};
|
|
35968
|
+
};
|
|
35969
|
+
//#endregion
|
|
36185
35970
|
//#region src/auth/server/zendesk-grant.ts
|
|
36186
35971
|
const ZENDESK_REFRESH_MARGIN_MS = 3e5;
|
|
36187
35972
|
const grantUnavailableError = () => Object.assign(/* @__PURE__ */ new Error("The Zendesk authorization behind this grant is no longer valid."), { name: "ZendeskGrantUnavailable" });
|
|
@@ -36232,6 +36017,8 @@ const ACCESS_TOKEN_TTL_S = 3600;
|
|
|
36232
36017
|
const GRANT_TTL_S = 7776e3;
|
|
36233
36018
|
const SESSION_TTL_S = 43200;
|
|
36234
36019
|
const INTERACTION_TTL_S = 600;
|
|
36020
|
+
/** ASCII only: it reaches the client in a token endpoint error. */
|
|
36021
|
+
const ADMIN_ONLY_MESSAGE = "This connector is reserved for Zendesk administrators, and your Zendesk role is not admin.";
|
|
36235
36022
|
/** Scopes the server advertises and grants; `offline_access` is for clients (ChatGPT) that ask for it. */
|
|
36236
36023
|
const RESOURCE_SCOPES = ["read", "write"];
|
|
36237
36024
|
const policyWithUpstreamLogin = () => {
|
|
@@ -36297,10 +36084,10 @@ const buildProvider = (options) => {
|
|
|
36297
36084
|
defaultResource: () => resource,
|
|
36298
36085
|
useGrantedResource: () => true,
|
|
36299
36086
|
getResourceServerInfo: (_ctx, indicator) => {
|
|
36300
|
-
if (indicator !== resource) throw new InvalidTarget();
|
|
36087
|
+
if (indicator !== resource && indicator !== options.adminResource) throw new InvalidTarget();
|
|
36301
36088
|
return {
|
|
36302
36089
|
scope: scopes,
|
|
36303
|
-
audience:
|
|
36090
|
+
audience: indicator,
|
|
36304
36091
|
accessTokenTTL: ACCESS_TOKEN_TTL_S,
|
|
36305
36092
|
accessTokenFormat: "jwt",
|
|
36306
36093
|
jwt: { encrypt: {
|
|
@@ -36316,8 +36103,10 @@ const buildProvider = (options) => {
|
|
|
36316
36103
|
extraTokenClaims: async (_ctx, token) => {
|
|
36317
36104
|
if (token.kind !== "AccessToken" || !token.grantId) return void 0;
|
|
36318
36105
|
try {
|
|
36106
|
+
const zd = await options.zendeskGrants.accessToken(token.grantId);
|
|
36107
|
+
if (token.resourceServer?.audience === options.adminResource && !await options.isZendeskAdmin(zd)) throw new AccessDenied(ADMIN_ONLY_MESSAGE);
|
|
36319
36108
|
return {
|
|
36320
|
-
zd
|
|
36109
|
+
zd,
|
|
36321
36110
|
gid: token.grantId
|
|
36322
36111
|
};
|
|
36323
36112
|
} catch (err) {
|
|
@@ -36349,6 +36138,230 @@ const buildProvider = (options) => {
|
|
|
36349
36138
|
return provider;
|
|
36350
36139
|
};
|
|
36351
36140
|
//#endregion
|
|
36141
|
+
//#region src/auth/server/interactions.ts
|
|
36142
|
+
const PENDING_TTL_MS = 6e5;
|
|
36143
|
+
const CONSENT_MEMORY_TTL_S = 7776e3;
|
|
36144
|
+
const sendHtml = (res, status, html) => {
|
|
36145
|
+
res.writeHead(status, {
|
|
36146
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
36147
|
+
"Cache-Control": "no-store"
|
|
36148
|
+
});
|
|
36149
|
+
res.end(html);
|
|
36150
|
+
};
|
|
36151
|
+
const redirect = (res, location) => {
|
|
36152
|
+
res.writeHead(302, {
|
|
36153
|
+
Location: location,
|
|
36154
|
+
"Cache-Control": "no-store"
|
|
36155
|
+
});
|
|
36156
|
+
res.end();
|
|
36157
|
+
};
|
|
36158
|
+
const EXPIRED = "This sign-in has expired or was opened in another browser. Start the connection again from your MCP client.";
|
|
36159
|
+
const consentMemoryKey = (accountId, clientId, redirectUris) => createHash("sha256").update(JSON.stringify([
|
|
36160
|
+
accountId,
|
|
36161
|
+
clientId,
|
|
36162
|
+
[...redirectUris].sort(compareCodeUnits)
|
|
36163
|
+
])).digest("base64url");
|
|
36164
|
+
const isExpiredLink = (err) => err instanceof Error && (err.name === "SessionNotFound" || err.name === "InvalidRequest");
|
|
36165
|
+
const UID = /^[\w-]{1,64}$/;
|
|
36166
|
+
/**
|
|
36167
|
+
* The login and consent steps oidc-provider hands off to us. Login is always a
|
|
36168
|
+
* round trip to Zendesk (fresh tokens for every grant); consent is skipped for
|
|
36169
|
+
* trusted CIMD clients and for a client this user already approved with the
|
|
36170
|
+
* same redirect URIs, and shown otherwise (ADR, Consent).
|
|
36171
|
+
*/
|
|
36172
|
+
const createInteractionRoutes = (deps) => {
|
|
36173
|
+
const { provider, upstream, issuer } = deps;
|
|
36174
|
+
const logger = deps.logger ?? silentLogger;
|
|
36175
|
+
const callbackUrl = `${issuer}/oauth/callback`;
|
|
36176
|
+
const verifiers = createExpiringMap();
|
|
36177
|
+
const upstreamTokens = createExpiringMap();
|
|
36178
|
+
const requestsAdmin = (resource) => (Array.isArray(resource) ? resource : [resource]).includes(deps.adminResource);
|
|
36179
|
+
const finish = (req, res, result) => provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
|
|
36180
|
+
const deny = (req, res, description) => finish(req, res, {
|
|
36181
|
+
error: "access_denied",
|
|
36182
|
+
error_description: description
|
|
36183
|
+
});
|
|
36184
|
+
const startUpstreamLogin = (res, uid) => {
|
|
36185
|
+
const verifier = generateCodeVerifier();
|
|
36186
|
+
verifiers.set(uid, verifier, PENDING_TTL_MS);
|
|
36187
|
+
redirect(res, buildZendeskAuthorizeUrl(upstream, {
|
|
36188
|
+
redirectUri: callbackUrl,
|
|
36189
|
+
scope: requestedScope(deps.readOnly),
|
|
36190
|
+
state: uid,
|
|
36191
|
+
codeChallenge: generateCodeChallenge(verifier)
|
|
36192
|
+
}));
|
|
36193
|
+
};
|
|
36194
|
+
const approve = async (req, res, details, { accountId, clientId }, tokens) => {
|
|
36195
|
+
const grant = new provider.Grant({
|
|
36196
|
+
accountId,
|
|
36197
|
+
clientId
|
|
36198
|
+
});
|
|
36199
|
+
const missing = details.prompt.details;
|
|
36200
|
+
if (missing.missingOIDCScope?.length) grant.addOIDCScope(missing.missingOIDCScope.join(" "));
|
|
36201
|
+
for (const [indicator, scopes] of Object.entries(missing.missingResourceScopes ?? {})) grant.addResourceScope(indicator, scopes.join(" "));
|
|
36202
|
+
const grantId = await grant.save();
|
|
36203
|
+
await deps.zendeskGrants.save(grantId, tokens);
|
|
36204
|
+
logger.info("oauth_grant_created", { clientId });
|
|
36205
|
+
await finish(req, res, { consent: { grantId } });
|
|
36206
|
+
};
|
|
36207
|
+
const consentContext = async (details) => {
|
|
36208
|
+
const accountId = String(details.session?.accountId);
|
|
36209
|
+
const clientId = String(details.params["client_id"]);
|
|
36210
|
+
const client = await provider.Client.find(clientId);
|
|
36211
|
+
const redirectUris = client?.redirectUris ?? [];
|
|
36212
|
+
return {
|
|
36213
|
+
client,
|
|
36214
|
+
accountId,
|
|
36215
|
+
clientId,
|
|
36216
|
+
pendingKey: `${accountId}|${clientId}`,
|
|
36217
|
+
memoryKey: consentMemoryKey(accountId, clientId, redirectUris),
|
|
36218
|
+
redirectUri: String(details.params["redirect_uri"]),
|
|
36219
|
+
redirectUris
|
|
36220
|
+
};
|
|
36221
|
+
};
|
|
36222
|
+
const showInteraction = async (req, res, uid) => {
|
|
36223
|
+
const details = await provider.interactionDetails(req, res);
|
|
36224
|
+
if (details.prompt.name === "login") {
|
|
36225
|
+
startUpstreamLogin(res, uid);
|
|
36226
|
+
return;
|
|
36227
|
+
}
|
|
36228
|
+
const context = await consentContext(details);
|
|
36229
|
+
if (canSkipConsent({
|
|
36230
|
+
clientId: context.clientId,
|
|
36231
|
+
redirectUri: context.redirectUri,
|
|
36232
|
+
registeredRedirectUris: context.redirectUris
|
|
36233
|
+
}, deps.trustedClients) || await deps.consentMemory.get(context.memoryKey) === true) {
|
|
36234
|
+
const tokens = upstreamTokens.take(context.pendingKey);
|
|
36235
|
+
if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
|
|
36236
|
+
return approve(req, res, details, context, tokens);
|
|
36237
|
+
}
|
|
36238
|
+
const missing = details.prompt.details;
|
|
36239
|
+
const scopes = [...new Set(Object.values(missing.missingResourceScopes ?? {}).flat())];
|
|
36240
|
+
sendHtml(res, 200, renderConsentPage({
|
|
36241
|
+
uid,
|
|
36242
|
+
clientName: context.client?.clientName,
|
|
36243
|
+
clientId: context.clientId,
|
|
36244
|
+
redirectUri: context.redirectUri,
|
|
36245
|
+
scopes,
|
|
36246
|
+
subdomain: upstream.subdomain
|
|
36247
|
+
}));
|
|
36248
|
+
};
|
|
36249
|
+
const completeUpstreamLogin = async (req, res, uid, url) => {
|
|
36250
|
+
const details = await provider.interactionDetails(req, res);
|
|
36251
|
+
const verifier = verifiers.take(uid);
|
|
36252
|
+
const code = url.searchParams.get("code");
|
|
36253
|
+
if (!verifier || !code) return deny(req, res, "The Zendesk sign-in was cancelled or refused.");
|
|
36254
|
+
try {
|
|
36255
|
+
const tokens = await exchangeZendeskCode(upstream, {
|
|
36256
|
+
code,
|
|
36257
|
+
redirectUri: callbackUrl,
|
|
36258
|
+
codeVerifier: verifier
|
|
36259
|
+
}, logger);
|
|
36260
|
+
const identity = await fetchZendeskIdentity(upstream, tokens.accessToken);
|
|
36261
|
+
if (requestsAdmin(details.params["resource"]) && identity.role !== "admin") {
|
|
36262
|
+
logger.info("oauth_admin_sign_in_refused", { role: identity.role ?? "unknown" });
|
|
36263
|
+
return deny(req, res, ADMIN_ONLY_MESSAGE);
|
|
36264
|
+
}
|
|
36265
|
+
const accountId = `zendesk:${identity.id}`;
|
|
36266
|
+
upstreamTokens.set(`${accountId}|${String(details.params["client_id"])}`, tokens, PENDING_TTL_MS);
|
|
36267
|
+
await finish(req, res, { login: { accountId } });
|
|
36268
|
+
} catch (err) {
|
|
36269
|
+
if (!isUpstreamAuthError(err)) throw err;
|
|
36270
|
+
logger.warn("oauth_upstream_login_failed", { error: err.message });
|
|
36271
|
+
await deny(req, res, err.message);
|
|
36272
|
+
}
|
|
36273
|
+
};
|
|
36274
|
+
const confirm = async (req, res) => {
|
|
36275
|
+
const details = await provider.interactionDetails(req, res);
|
|
36276
|
+
if (details.prompt.name !== "consent") return sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
36277
|
+
const context = await consentContext(details);
|
|
36278
|
+
const tokens = upstreamTokens.take(context.pendingKey);
|
|
36279
|
+
if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
|
|
36280
|
+
await deps.consentMemory.set(context.memoryKey, true, CONSENT_MEMORY_TTL_S);
|
|
36281
|
+
return approve(req, res, details, context, tokens);
|
|
36282
|
+
};
|
|
36283
|
+
const routes = async (req, res, url) => {
|
|
36284
|
+
const [, , uid = "", action] = url.pathname.split("/");
|
|
36285
|
+
if (!UID.test(uid)) return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
|
|
36286
|
+
if (req.method === "GET" && action === void 0) return showInteraction(req, res, uid);
|
|
36287
|
+
if (req.method === "GET" && action === "callback") return completeUpstreamLogin(req, res, uid, url);
|
|
36288
|
+
if (req.method === "POST" && action === "confirm") return confirm(req, res);
|
|
36289
|
+
if (req.method === "POST" && action === "abort") {
|
|
36290
|
+
await provider.interactionDetails(req, res);
|
|
36291
|
+
return deny(req, res, "The user declined access.");
|
|
36292
|
+
}
|
|
36293
|
+
return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
|
|
36294
|
+
};
|
|
36295
|
+
return {
|
|
36296
|
+
/** `/oauth/callback`: the one redirect URI registered at Zendesk. */
|
|
36297
|
+
upstreamCallback: (res, url) => {
|
|
36298
|
+
const uid = url.searchParams.get("state") ?? "";
|
|
36299
|
+
if (!UID.test(uid)) {
|
|
36300
|
+
sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
36301
|
+
return;
|
|
36302
|
+
}
|
|
36303
|
+
const next = new URL(`/interaction/${uid}/callback`, issuer);
|
|
36304
|
+
const code = url.searchParams.get("code");
|
|
36305
|
+
if (code) next.searchParams.set("code", code);
|
|
36306
|
+
redirect(res, `${next.pathname}${next.search}`);
|
|
36307
|
+
},
|
|
36308
|
+
/** `/interaction/<uid>[/callback|/confirm|/abort]`. */
|
|
36309
|
+
handle: async (req, res, url) => {
|
|
36310
|
+
try {
|
|
36311
|
+
await routes(req, res, url);
|
|
36312
|
+
} catch (err) {
|
|
36313
|
+
if (!isExpiredLink(err)) throw err;
|
|
36314
|
+
if (!res.headersSent) sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
|
|
36315
|
+
}
|
|
36316
|
+
}
|
|
36317
|
+
};
|
|
36318
|
+
};
|
|
36319
|
+
//#endregion
|
|
36320
|
+
//#region src/auth/server/keys.ts
|
|
36321
|
+
const MIN_SECRET_BYTES = 32;
|
|
36322
|
+
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
36323
|
+
const derive = (master, purpose, length = 32) => Buffer.from(hkdfSync("sha256", master, Buffer.alloc(0), `zendesk-mcp/${purpose}/v1`, length));
|
|
36324
|
+
const kidFor = (master, purpose) => createHash("sha256").update(derive(master, `kid/${purpose}`, 16)).digest("base64url").slice(0, 16);
|
|
36325
|
+
/**
|
|
36326
|
+
* Parse `OAUTH_MASTER_SECRET`: base64 entries, comma-separated, newest first.
|
|
36327
|
+
* Errors never echo the value.
|
|
36328
|
+
*/
|
|
36329
|
+
const parseSecretList = (raw) => {
|
|
36330
|
+
const entries = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((entry) => Buffer.from(entry, "base64"));
|
|
36331
|
+
const [first, ...rest] = entries;
|
|
36332
|
+
if (!first) throw new Error("OAuth master secret is empty.");
|
|
36333
|
+
if (entries.some((secret) => secret.length < 32)) throw new Error(`OAuth master secret must decode to at least 32 bytes of base64 (generate one with: openssl rand -base64 32).`);
|
|
36334
|
+
return [first, ...rest];
|
|
36335
|
+
};
|
|
36336
|
+
const deriveKeySet = (master) => {
|
|
36337
|
+
return {
|
|
36338
|
+
signingJwk: {
|
|
36339
|
+
...createPrivateKey({
|
|
36340
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, derive(master, "signing")]),
|
|
36341
|
+
format: "der",
|
|
36342
|
+
type: "pkcs8"
|
|
36343
|
+
}).export({ format: "jwk" }),
|
|
36344
|
+
kid: kidFor(master, "signing"),
|
|
36345
|
+
use: "sig",
|
|
36346
|
+
alg: "EdDSA"
|
|
36347
|
+
},
|
|
36348
|
+
accessToken: {
|
|
36349
|
+
kid: kidFor(master, "access-token"),
|
|
36350
|
+
key: createSecretKey(derive(master, "access-token"))
|
|
36351
|
+
},
|
|
36352
|
+
atRest: {
|
|
36353
|
+
kid: kidFor(master, "at-rest"),
|
|
36354
|
+
key: createSecretKey(derive(master, "at-rest"))
|
|
36355
|
+
},
|
|
36356
|
+
cookieKey: derive(master, "cookie").toString("base64url"),
|
|
36357
|
+
requestState: derive(master, "request-state")
|
|
36358
|
+
};
|
|
36359
|
+
};
|
|
36360
|
+
const deriveKeyRing = (raw) => {
|
|
36361
|
+
const [first, ...rest] = parseSecretList(raw);
|
|
36362
|
+
return [deriveKeySet(first), ...rest.map(deriveKeySet)];
|
|
36363
|
+
};
|
|
36364
|
+
//#endregion
|
|
36352
36365
|
//#region src/auth/server/secret.ts
|
|
36353
36366
|
const MASTER_SECRET_FILE_NAME = "oauth-master-secret";
|
|
36354
36367
|
const isWindows = process.platform === "win32";
|
|
@@ -36439,6 +36452,7 @@ const createAuthorizationServer = (options) => {
|
|
|
36439
36452
|
const { config, issuer, ring, persistent } = options;
|
|
36440
36453
|
const logger = options.logger ?? silentLogger;
|
|
36441
36454
|
const resource = `${issuer}/mcp`;
|
|
36455
|
+
const adminResource = `${issuer}/mcp/admin`;
|
|
36442
36456
|
const upstream = {
|
|
36443
36457
|
subdomain: config.subdomain,
|
|
36444
36458
|
clientId: config.oauthClientId
|
|
@@ -36462,6 +36476,16 @@ const createAuthorizationServer = (options) => {
|
|
|
36462
36476
|
const provider = buildProvider({
|
|
36463
36477
|
issuer,
|
|
36464
36478
|
resource,
|
|
36479
|
+
adminResource,
|
|
36480
|
+
isZendeskAdmin: async (zendeskAccessToken) => {
|
|
36481
|
+
try {
|
|
36482
|
+
return (await fetchZendeskIdentity(upstream, zendeskAccessToken)).role === ZENDESK_ADMIN_ROLE;
|
|
36483
|
+
} catch (err) {
|
|
36484
|
+
const { cause } = err;
|
|
36485
|
+
if (cause instanceof ZendeskApiError && cause.status === 401) throw grantUnavailableError();
|
|
36486
|
+
throw err;
|
|
36487
|
+
}
|
|
36488
|
+
},
|
|
36465
36489
|
ring,
|
|
36466
36490
|
adapter: createAdapterFactory({ persistent }, ring),
|
|
36467
36491
|
zendeskGrants,
|
|
@@ -36485,6 +36509,7 @@ const createAuthorizationServer = (options) => {
|
|
|
36485
36509
|
provider,
|
|
36486
36510
|
upstream,
|
|
36487
36511
|
issuer,
|
|
36512
|
+
adminResource,
|
|
36488
36513
|
readOnly: config.readOnly,
|
|
36489
36514
|
zendeskGrants,
|
|
36490
36515
|
consentMemory: createSealedCollection(persistent, "Consent", ring),
|
|
@@ -36499,16 +36524,19 @@ const createAuthorizationServer = (options) => {
|
|
|
36499
36524
|
const { plaintext } = await compactDecrypt(bearer, keySet.accessToken.key);
|
|
36500
36525
|
return asVerified(JSON.parse(decoder.decode(plaintext)), issuer);
|
|
36501
36526
|
};
|
|
36527
|
+
const metadataOf = (of) => ({
|
|
36528
|
+
resource: of,
|
|
36529
|
+
authorization_servers: [issuer],
|
|
36530
|
+
bearer_methods_supported: ["header"],
|
|
36531
|
+
scopes_supported: supportedScopes(config.readOnly)
|
|
36532
|
+
});
|
|
36502
36533
|
return {
|
|
36503
36534
|
issuer,
|
|
36504
36535
|
resource,
|
|
36536
|
+
adminResource,
|
|
36505
36537
|
requestStateKey: ring[0].requestState,
|
|
36506
|
-
protectedResourceMetadata:
|
|
36507
|
-
|
|
36508
|
-
authorization_servers: [issuer],
|
|
36509
|
-
bearer_methods_supported: ["header"],
|
|
36510
|
-
scopes_supported: supportedScopes(config.readOnly)
|
|
36511
|
-
},
|
|
36538
|
+
protectedResourceMetadata: metadataOf(resource),
|
|
36539
|
+
adminProtectedResourceMetadata: metadataOf(adminResource),
|
|
36512
36540
|
handle: async (req, res) => {
|
|
36513
36541
|
const url = new URL(req.url ?? "/", issuer);
|
|
36514
36542
|
if (url.pathname === "/oauth/callback" && req.method === "GET") {
|
|
@@ -36568,6 +36596,2359 @@ const prepareAuthorizationServer = async (config, logger = silentLogger) => {
|
|
|
36568
36596
|
});
|
|
36569
36597
|
};
|
|
36570
36598
|
//#endregion
|
|
36599
|
+
//#region node_modules/.pnpm/@modelcontextprotocol+core@2.4.0/node_modules/@modelcontextprotocol/core/dist/ext/skills/index.mjs
|
|
36600
|
+
/**
|
|
36601
|
+
* Wire constants for the MCP Skills extension (SEP-2640).
|
|
36602
|
+
*
|
|
36603
|
+
* Runtime-neutral: this module imports nothing.
|
|
36604
|
+
*/
|
|
36605
|
+
/** The identifier a server advertises in `ServerCapabilities.extensions`. */
|
|
36606
|
+
const SKILLS_EXTENSION_ID = "io.modelcontextprotocol/skills";
|
|
36607
|
+
/** Enumerates the skills a server serves. */
|
|
36608
|
+
const SKILLS_LIST_METHOD = "skills/list";
|
|
36609
|
+
/** Returns one skill's entry by its `SKILL.md` URI, listed or not. */
|
|
36610
|
+
const SKILLS_GET_METHOD = "skills/get";
|
|
36611
|
+
/** Lists the direct children of a directory resource (gated by `directoryRead`). */
|
|
36612
|
+
const RESOURCES_DIRECTORY_READ_METHOD = "resources/directory/read";
|
|
36613
|
+
/** The `mimeType` of a directory resource. */
|
|
36614
|
+
const DIRECTORY_MIME_TYPE = "inode/directory";
|
|
36615
|
+
/**
|
|
36616
|
+
* Per-skill interoperability limits. Hosts MUST accept skills up to these;
|
|
36617
|
+
* servers SHOULD NOT publish skills over them.
|
|
36618
|
+
*/
|
|
36619
|
+
const SKILL_LIMITS = Object.freeze({
|
|
36620
|
+
files: 512,
|
|
36621
|
+
bytes: 16777216
|
|
36622
|
+
});
|
|
36623
|
+
/** `sha256:` followed by 64 lowercase hexadecimal characters. */
|
|
36624
|
+
const SKILL_DIGEST_PATTERN = /^sha256:[\da-f]{64}$/;
|
|
36625
|
+
const SkillDigestSchema = string().regex(SKILL_DIGEST_PATTERN, "digest must be \"sha256:\" followed by 64 lowercase hexadecimal characters");
|
|
36626
|
+
const SkillResourceEntrySchema = looseObject({
|
|
36627
|
+
uri: string(),
|
|
36628
|
+
digest: SkillDigestSchema,
|
|
36629
|
+
size: number().int().nonnegative()
|
|
36630
|
+
});
|
|
36631
|
+
const SkillFrontmatterSchema = looseObject({
|
|
36632
|
+
name: string(),
|
|
36633
|
+
description: string()
|
|
36634
|
+
}).refine((frontmatter) => Object.getPrototypeOf(frontmatter) === Object.prototype, "frontmatter must not have a \"__proto__\" field");
|
|
36635
|
+
const SkillResourcesSchema = union([array(SkillResourceEntrySchema), literal("dynamic")]);
|
|
36636
|
+
const SkillSchema = looseObject({
|
|
36637
|
+
uri: string(),
|
|
36638
|
+
frontmatter: SkillFrontmatterSchema,
|
|
36639
|
+
resources: SkillResourcesSchema,
|
|
36640
|
+
_meta: record(string(), unknown()).optional()
|
|
36641
|
+
});
|
|
36642
|
+
const SkillCacheScopeSchema = _enum(["public", "private"]);
|
|
36643
|
+
const cacheFields = {
|
|
36644
|
+
ttlMs: number().int().nonnegative(),
|
|
36645
|
+
cacheScope: SkillCacheScopeSchema
|
|
36646
|
+
};
|
|
36647
|
+
const ListSkillsResultSchema = looseObject({
|
|
36648
|
+
...PaginatedResultSchema.shape,
|
|
36649
|
+
skills: array(SkillSchema),
|
|
36650
|
+
...cacheFields
|
|
36651
|
+
});
|
|
36652
|
+
const GetSkillRequestParamsSchema = BaseRequestParamsSchema.extend({ uri: string() });
|
|
36653
|
+
const GetSkillResultSchema = looseObject({
|
|
36654
|
+
...ResultSchema.shape,
|
|
36655
|
+
skill: SkillSchema,
|
|
36656
|
+
...cacheFields
|
|
36657
|
+
});
|
|
36658
|
+
const DirectoryResourceSchema = looseObject(ResourceSchema.shape);
|
|
36659
|
+
const ReadResourceDirectoryRequestParamsSchema = PaginatedRequestParamsSchema.extend({ uri: string() });
|
|
36660
|
+
const ReadResourceDirectoryResultSchema = looseObject({
|
|
36661
|
+
...PaginatedResultSchema.shape,
|
|
36662
|
+
resources: array(DirectoryResourceSchema),
|
|
36663
|
+
...cacheFields
|
|
36664
|
+
});
|
|
36665
|
+
const SkillsCapabilitySchema = looseObject({ directoryRead: boolean$1().optional() });
|
|
36666
|
+
//#endregion
|
|
36667
|
+
//#region node_modules/.pnpm/@modelcontextprotocol+server@2.4.0/node_modules/@modelcontextprotocol/server/dist/extension-Pm4HwRE6.mjs
|
|
36668
|
+
/**
|
|
36669
|
+
* Thrown when a skill catalog cannot be built: storage failures always, skill problems under `strict`.
|
|
36670
|
+
* Branded, so `instanceof` also matches an instance from another bundled copy of the SDK.
|
|
36671
|
+
*/
|
|
36672
|
+
var SkillCatalogError = class extends Error {
|
|
36673
|
+
static {
|
|
36674
|
+
Object.defineProperty(this, "mcpBrand", { value: "mcp.SkillCatalogError" });
|
|
36675
|
+
}
|
|
36676
|
+
static [Symbol.hasInstance](value) {
|
|
36677
|
+
return brandedHasInstance(this, value);
|
|
36678
|
+
}
|
|
36679
|
+
/**
|
|
36680
|
+
* Brand-based type guard: equivalent to `value instanceof this`, as an
|
|
36681
|
+
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Must be
|
|
36682
|
+
* invoked on the class — in callback position write
|
|
36683
|
+
* `v => SkillCatalogError.isInstance(v)`, not `.filter(SkillCatalogError.isInstance)`.
|
|
36684
|
+
*/
|
|
36685
|
+
static isInstance(value) {
|
|
36686
|
+
if (typeof this !== "function") throw new TypeError("isInstance must be called on the class (e.g. `SkillCatalogError.isInstance(value)`); for callbacks use `v => SkillCatalogError.isInstance(v)`");
|
|
36687
|
+
return brandedHasInstance(this, value);
|
|
36688
|
+
}
|
|
36689
|
+
problems;
|
|
36690
|
+
constructor(problems) {
|
|
36691
|
+
const lines = problems.map((problem) => ` ${problem.path}: ${problem.message}`).join("\n");
|
|
36692
|
+
super(`${problems.length} skill problem${problems.length === 1 ? "" : "s"}:\n${lines}`);
|
|
36693
|
+
this.name = "SkillCatalogError";
|
|
36694
|
+
this.problems = problems;
|
|
36695
|
+
stampErrorBrands(this, new.target);
|
|
36696
|
+
}
|
|
36697
|
+
};
|
|
36698
|
+
function defaultWarn(warning) {
|
|
36699
|
+
console.warn(`[mcp-sdk] skills: ${warning.path}: ${warning.message}`);
|
|
36700
|
+
}
|
|
36701
|
+
const encoder$1 = new TextEncoder();
|
|
36702
|
+
/** UTF-8 bytes of `text`. */
|
|
36703
|
+
function encodeUtf8(text) {
|
|
36704
|
+
return encoder$1.encode(text);
|
|
36705
|
+
}
|
|
36706
|
+
/** Decodes UTF-8 only if decoding loses nothing (a BOM is kept); otherwise `undefined`. */
|
|
36707
|
+
function decodeUtf8Lossless(bytes) {
|
|
36708
|
+
try {
|
|
36709
|
+
return new TextDecoder("utf-8", {
|
|
36710
|
+
fatal: true,
|
|
36711
|
+
ignoreBOM: true
|
|
36712
|
+
}).decode(bytes);
|
|
36713
|
+
} catch {
|
|
36714
|
+
return;
|
|
36715
|
+
}
|
|
36716
|
+
}
|
|
36717
|
+
/** Standard base64 of `bytes`. */
|
|
36718
|
+
function encodeBase64(bytes) {
|
|
36719
|
+
let binary = "";
|
|
36720
|
+
const chunk = 32768;
|
|
36721
|
+
for (let offset = 0; offset < bytes.length; offset += chunk) binary += String.fromCodePoint(...bytes.subarray(offset, offset + chunk));
|
|
36722
|
+
return btoa(binary);
|
|
36723
|
+
}
|
|
36724
|
+
/** Bytes of a standard base64 string. */
|
|
36725
|
+
function decodeBase64(base64) {
|
|
36726
|
+
const binary = atob(base64);
|
|
36727
|
+
const bytes = new Uint8Array(binary.length);
|
|
36728
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.codePointAt(index);
|
|
36729
|
+
return bytes;
|
|
36730
|
+
}
|
|
36731
|
+
/** `sha256:` followed by the lowercase hex SHA-256 of `bytes` (WebCrypto, every runtime). */
|
|
36732
|
+
async function sha256Digest(bytes) {
|
|
36733
|
+
const { buffer, byteOffset, byteLength } = bytes;
|
|
36734
|
+
const view = buffer instanceof ArrayBuffer ? new Uint8Array(buffer, byteOffset, byteLength) : new Uint8Array(bytes);
|
|
36735
|
+
const hash = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", view));
|
|
36736
|
+
let hex = "";
|
|
36737
|
+
for (const byte of hash) hex += byte.toString(16).padStart(2, "0");
|
|
36738
|
+
return `sha256:${hex}`;
|
|
36739
|
+
}
|
|
36740
|
+
/**
|
|
36741
|
+
* Frontmatter reader for `SKILL.md` (SEP-2640).
|
|
36742
|
+
*
|
|
36743
|
+
* Reads the YAML subset real skills use — block mappings and sequences;
|
|
36744
|
+
* plain, quoted and block scalars; one-line flow collections; comments — with
|
|
36745
|
+
* YAML 1.2 core-schema typing. Anything outside the subset throws a
|
|
36746
|
+
* {@linkcode FrontmatterError} instead of being read differently from a full
|
|
36747
|
+
* YAML parser. Server catalogs and client verification both call it; it is
|
|
36748
|
+
* kept behind this module so it can be swapped for a YAML library.
|
|
36749
|
+
*/
|
|
36750
|
+
var FrontmatterError = class extends Error {
|
|
36751
|
+
line;
|
|
36752
|
+
constructor(message, line) {
|
|
36753
|
+
super(line === void 0 ? message : `line ${line}: ${message}`);
|
|
36754
|
+
this.name = "FrontmatterError";
|
|
36755
|
+
this.line = line;
|
|
36756
|
+
}
|
|
36757
|
+
};
|
|
36758
|
+
const MAX_DEPTH = 32;
|
|
36759
|
+
/** Splits `SKILL.md` into its frontmatter YAML and markdown body. */
|
|
36760
|
+
function splitFrontmatter(markdown) {
|
|
36761
|
+
let text = markdown.replaceAll("\r\n", "\n");
|
|
36762
|
+
if (text.codePointAt(0) === 65279) text = text.slice(1);
|
|
36763
|
+
if (!text.startsWith("---\n")) throw new FrontmatterError("SKILL.md must begin with a \"---\" line", 1);
|
|
36764
|
+
const rest = text.slice(4);
|
|
36765
|
+
const close = /^---[\t ]*$/m.exec(rest);
|
|
36766
|
+
if (close === null) throw new FrontmatterError("frontmatter has no closing \"---\" line");
|
|
36767
|
+
let body = rest.slice(close.index + close[0].length);
|
|
36768
|
+
if (body.startsWith("\n")) body = body.slice(1);
|
|
36769
|
+
return {
|
|
36770
|
+
yaml: rest.slice(0, close.index),
|
|
36771
|
+
body
|
|
36772
|
+
};
|
|
36773
|
+
}
|
|
36774
|
+
/** Splits and parses `SKILL.md`. Line numbers in errors count from the file's first line. */
|
|
36775
|
+
function parseSkillMarkdown(markdown) {
|
|
36776
|
+
const { yaml, body } = splitFrontmatter(markdown);
|
|
36777
|
+
return {
|
|
36778
|
+
frontmatter: parseFrontmatterYaml(yaml, 2),
|
|
36779
|
+
body
|
|
36780
|
+
};
|
|
36781
|
+
}
|
|
36782
|
+
/** Parses frontmatter YAML into a JSON object. `firstLine` numbers the first YAML line in errors. */
|
|
36783
|
+
function parseFrontmatterYaml(yaml, firstLine = 1) {
|
|
36784
|
+
return new Parser(toLines(yaml, firstLine)).parseDocument();
|
|
36785
|
+
}
|
|
36786
|
+
function toLines(yaml, firstLine) {
|
|
36787
|
+
const raws = yaml.split("\n");
|
|
36788
|
+
if (raws.at(-1) === "") raws.pop();
|
|
36789
|
+
return raws.map((raw, index) => ({
|
|
36790
|
+
no: firstLine + index,
|
|
36791
|
+
indent: /^ */.exec(raw)?.[0].length ?? 0,
|
|
36792
|
+
text: trimWhiteStart(raw),
|
|
36793
|
+
raw
|
|
36794
|
+
}));
|
|
36795
|
+
}
|
|
36796
|
+
const TAB_INDENT = "tabs cannot be used for indentation";
|
|
36797
|
+
const isSkippable = (line) => line.text === "" || line.text.startsWith("#");
|
|
36798
|
+
const isTabIndented = (line) => line.raw[line.indent] === " ";
|
|
36799
|
+
const isSequenceItem = (text) => /^-(?:[\t ]|$)/.test(text);
|
|
36800
|
+
var Parser = class {
|
|
36801
|
+
index = 0;
|
|
36802
|
+
constructor(lines) {
|
|
36803
|
+
this.lines = lines;
|
|
36804
|
+
}
|
|
36805
|
+
parseDocument() {
|
|
36806
|
+
const first = this.peek();
|
|
36807
|
+
if (first === void 0) return {};
|
|
36808
|
+
if (first.text.startsWith("")) throw new FrontmatterError("a byte order mark inside the frontmatter is not supported", first.no);
|
|
36809
|
+
if (isSequenceItem(first.text) || matchKey(first.text, first.no) === void 0) throw new FrontmatterError("frontmatter must be a mapping of keys to values", first.no);
|
|
36810
|
+
const map = this.parseMapping(first.indent, 0);
|
|
36811
|
+
const extra = this.peek();
|
|
36812
|
+
if (extra !== void 0) throw new FrontmatterError("unexpected content", extra.no);
|
|
36813
|
+
return map;
|
|
36814
|
+
}
|
|
36815
|
+
peek() {
|
|
36816
|
+
while (this.index < this.lines.length && isSkippable(this.lines[this.index])) this.index++;
|
|
36817
|
+
const line = this.lines[this.index];
|
|
36818
|
+
if (line !== void 0 && isTabIndented(line)) throw new FrontmatterError(TAB_INDENT, line.no);
|
|
36819
|
+
return line;
|
|
36820
|
+
}
|
|
36821
|
+
parseMapping(indent, depth) {
|
|
36822
|
+
assertDepth(depth, this.lines[this.index]?.no);
|
|
36823
|
+
const map = {};
|
|
36824
|
+
for (let line = this.peek(); line !== void 0 && line.indent >= indent; line = this.peek()) {
|
|
36825
|
+
if (line.indent > indent) throw new FrontmatterError("unexpected indentation", line.no);
|
|
36826
|
+
if (isSequenceItem(line.text)) break;
|
|
36827
|
+
const match = matchKey(line.text, line.no);
|
|
36828
|
+
if (match === void 0) throw new FrontmatterError("expected \"key: value\"", line.no);
|
|
36829
|
+
if (Object.hasOwn(map, match.key)) throw new FrontmatterError(`duplicate key "${match.key}"`, line.no);
|
|
36830
|
+
this.index++;
|
|
36831
|
+
map[match.key] = this.parseValue(match.rest, line, indent, depth + 1, true);
|
|
36832
|
+
}
|
|
36833
|
+
return map;
|
|
36834
|
+
}
|
|
36835
|
+
parseSequence(indent, depth) {
|
|
36836
|
+
assertDepth(depth, this.lines[this.index]?.no);
|
|
36837
|
+
const items = [];
|
|
36838
|
+
for (let line = this.peek(); line !== void 0 && line.indent >= indent; line = this.peek()) {
|
|
36839
|
+
if (line.indent > indent) throw new FrontmatterError("unexpected indentation", line.no);
|
|
36840
|
+
if (!isSequenceItem(line.text)) break;
|
|
36841
|
+
const after = line.text.slice(1);
|
|
36842
|
+
const rest = trimWhiteStart(after);
|
|
36843
|
+
const column = line.indent + 1 + (after.length - rest.length);
|
|
36844
|
+
if (rest !== "" && !rest.startsWith("#") && (isSequenceItem(rest) || matchKey(rest, line.no) !== void 0)) {
|
|
36845
|
+
if (/^ *\t/.test(after)) throw new FrontmatterError(TAB_INDENT, line.no);
|
|
36846
|
+
this.lines[this.index] = {
|
|
36847
|
+
...line,
|
|
36848
|
+
indent: column,
|
|
36849
|
+
text: rest
|
|
36850
|
+
};
|
|
36851
|
+
items.push(isSequenceItem(rest) ? this.parseSequence(column, depth + 1) : this.parseMapping(column, depth + 1));
|
|
36852
|
+
continue;
|
|
36853
|
+
}
|
|
36854
|
+
this.index++;
|
|
36855
|
+
items.push(this.parseValue(rest, line, indent, depth + 1, false));
|
|
36856
|
+
}
|
|
36857
|
+
return items;
|
|
36858
|
+
}
|
|
36859
|
+
parseValue(rest, line, parentIndent, depth, allowCompactSequence) {
|
|
36860
|
+
if (rest === "" || rest.startsWith("#")) {
|
|
36861
|
+
const next = this.peek();
|
|
36862
|
+
if (next !== void 0 && next.indent > parentIndent) return this.parseIndented(parentIndent, depth);
|
|
36863
|
+
if (allowCompactSequence && next !== void 0 && next.indent === parentIndent && isSequenceItem(next.text)) return this.parseSequence(parentIndent, depth);
|
|
36864
|
+
return null;
|
|
36865
|
+
}
|
|
36866
|
+
const first = rest[0];
|
|
36867
|
+
if (first === "|" || first === ">") return this.parseBlockScalar(rest, line, parentIndent);
|
|
36868
|
+
if (first === "\"" || first === "'") {
|
|
36869
|
+
const { value, end } = readQuoted(rest, 0, line.no);
|
|
36870
|
+
expectOnlyComment(rest.slice(end), line.no);
|
|
36871
|
+
return value;
|
|
36872
|
+
}
|
|
36873
|
+
if (first === "[" || first === "{") {
|
|
36874
|
+
const { value, end } = readFlow(rest, 0, line.no, depth);
|
|
36875
|
+
expectOnlyComment(rest.slice(end), line.no);
|
|
36876
|
+
return value;
|
|
36877
|
+
}
|
|
36878
|
+
if ("&*!".includes(first)) throw new FrontmatterError("anchors, aliases and tags are not supported", line.no);
|
|
36879
|
+
if ("%@`,]}".includes(first)) throw new FrontmatterError(`"${first}" cannot start a plain value; quote it`, line.no);
|
|
36880
|
+
if (isSequenceItem(rest) || /^\?(?:[\t ]|$)/.test(rest)) throw new FrontmatterError("a nested collection must start on its own line", line.no);
|
|
36881
|
+
return this.parsePlain(rest, line, parentIndent);
|
|
36882
|
+
}
|
|
36883
|
+
parseIndented(parentIndent, depth) {
|
|
36884
|
+
const line = this.peek();
|
|
36885
|
+
if (isSequenceItem(line.text)) return this.parseSequence(line.indent, depth);
|
|
36886
|
+
if (matchKey(line.text, line.no) !== void 0) return this.parseMapping(line.indent, depth);
|
|
36887
|
+
this.index++;
|
|
36888
|
+
return this.parseValue(line.text, line, parentIndent, depth, false);
|
|
36889
|
+
}
|
|
36890
|
+
parsePlain(first, line, parentIndent) {
|
|
36891
|
+
let value = stripComment(first);
|
|
36892
|
+
assertPlain(value, line.no);
|
|
36893
|
+
let commented = COMMENT.test(first);
|
|
36894
|
+
let blanks = 0;
|
|
36895
|
+
while (this.index < this.lines.length) {
|
|
36896
|
+
const next = this.lines[this.index];
|
|
36897
|
+
if (next.text === "") {
|
|
36898
|
+
blanks++;
|
|
36899
|
+
this.index++;
|
|
36900
|
+
continue;
|
|
36901
|
+
}
|
|
36902
|
+
if (next.indent <= parentIndent || next.text.startsWith("#")) break;
|
|
36903
|
+
if (isTabIndented(next)) throw new FrontmatterError(TAB_INDENT, next.no);
|
|
36904
|
+
if (commented) throw new FrontmatterError("a comment ends a plain value; quote a value that continues past it", next.no);
|
|
36905
|
+
const part = stripComment(next.text);
|
|
36906
|
+
assertPlain(part, next.no);
|
|
36907
|
+
commented = COMMENT.test(next.text);
|
|
36908
|
+
value += blanks > 0 ? "\n".repeat(blanks) : " ";
|
|
36909
|
+
value += part;
|
|
36910
|
+
blanks = 0;
|
|
36911
|
+
this.index++;
|
|
36912
|
+
}
|
|
36913
|
+
return resolvePlain(value, line.no);
|
|
36914
|
+
}
|
|
36915
|
+
parseBlockScalar(header, line, parentIndent) {
|
|
36916
|
+
const match = /^([>|])([+-]?)(?:[\t ]+#.*)?[\t ]*$/.exec(header);
|
|
36917
|
+
if (match === null) throw new FrontmatterError(/^[>|][+-]?\d|^[>|]\d/.test(header) ? "block scalar indentation indicators are not supported" : "invalid block scalar header", line.no);
|
|
36918
|
+
const contents = [];
|
|
36919
|
+
let contentIndent = -1;
|
|
36920
|
+
let leadingWidth = 0;
|
|
36921
|
+
while (this.index < this.lines.length) {
|
|
36922
|
+
const next = this.lines[this.index];
|
|
36923
|
+
const spacesOnly = next.indent === next.raw.length;
|
|
36924
|
+
if (contentIndent === -1 && !spacesOnly && next.indent > parentIndent) {
|
|
36925
|
+
contentIndent = next.indent;
|
|
36926
|
+
if (leadingWidth > contentIndent) throw new FrontmatterError("a leading blank line is indented more than the block scalar content", next.no);
|
|
36927
|
+
}
|
|
36928
|
+
if (contentIndent !== -1 && next.indent >= contentIndent) contents.push(next.raw.slice(contentIndent));
|
|
36929
|
+
else if (spacesOnly) {
|
|
36930
|
+
if (contentIndent === -1) leadingWidth = Math.max(leadingWidth, next.indent);
|
|
36931
|
+
contents.push("");
|
|
36932
|
+
} else if (isTabIndented(next)) throw new FrontmatterError(TAB_INDENT, next.no);
|
|
36933
|
+
else break;
|
|
36934
|
+
this.index++;
|
|
36935
|
+
}
|
|
36936
|
+
return foldBlock(contents, match[1] === ">", match[2] ?? "");
|
|
36937
|
+
}
|
|
36938
|
+
};
|
|
36939
|
+
function assertDepth(depth, line) {
|
|
36940
|
+
if (depth > MAX_DEPTH) throw new FrontmatterError(`nesting deeper than ${MAX_DEPTH} levels is not supported`, line);
|
|
36941
|
+
}
|
|
36942
|
+
function matchKey(text, no) {
|
|
36943
|
+
const first = text[0];
|
|
36944
|
+
if (first === "\"" || first === "'") {
|
|
36945
|
+
const { value, end } = readQuoted(text, 0, no);
|
|
36946
|
+
const after = text.slice(end);
|
|
36947
|
+
if (after === ":" || /^:[\t ]/.test(after)) return {
|
|
36948
|
+
key: checkKey(value, no),
|
|
36949
|
+
rest: trimWhiteStart(after.slice(1))
|
|
36950
|
+
};
|
|
36951
|
+
return;
|
|
36952
|
+
}
|
|
36953
|
+
if (first === "?") throw new FrontmatterError("complex keys (\"?\") are not supported", no);
|
|
36954
|
+
if (first === void 0 || "[]{}&*!|>%@`#,".includes(first)) return void 0;
|
|
36955
|
+
for (let index = 0; index < text.length; index++) {
|
|
36956
|
+
const char = text[index];
|
|
36957
|
+
const previous = text[index - 1];
|
|
36958
|
+
if (char === "#" && (previous === " " || previous === " ")) return void 0;
|
|
36959
|
+
if (char === ":") {
|
|
36960
|
+
const next = text[index + 1];
|
|
36961
|
+
if (next === void 0 || next === " " || next === " ") {
|
|
36962
|
+
const key = trimWhiteEnd(text.slice(0, index));
|
|
36963
|
+
if (key === "") return void 0;
|
|
36964
|
+
return {
|
|
36965
|
+
key: resolveKey(key, no),
|
|
36966
|
+
rest: trimWhiteStart(text.slice(index + 1))
|
|
36967
|
+
};
|
|
36968
|
+
}
|
|
36969
|
+
}
|
|
36970
|
+
}
|
|
36971
|
+
}
|
|
36972
|
+
function checkKey(key, no) {
|
|
36973
|
+
if (key === "__proto__") throw new FrontmatterError("the key \"__proto__\" is not supported", no);
|
|
36974
|
+
return key;
|
|
36975
|
+
}
|
|
36976
|
+
function resolveKey(raw, no) {
|
|
36977
|
+
if (typeof resolvePlain(raw, no) !== "string") throw new FrontmatterError(`key "${raw}" must be a string; quote it`, no);
|
|
36978
|
+
return checkKey(raw, no);
|
|
36979
|
+
}
|
|
36980
|
+
function assertPlain(text, no) {
|
|
36981
|
+
if (/:(?:[\t ]|$)/.test(text)) throw new FrontmatterError("a plain value cannot contain \": \"; quote the value", no);
|
|
36982
|
+
}
|
|
36983
|
+
const COMMENT = /[\t ]#/;
|
|
36984
|
+
function stripComment(text) {
|
|
36985
|
+
const match = COMMENT.exec(text);
|
|
36986
|
+
return trimWhiteEnd(match === null ? text : text.slice(0, match.index));
|
|
36987
|
+
}
|
|
36988
|
+
function expectOnlyComment(after, no) {
|
|
36989
|
+
if (trimWhiteStart(after) === "" || /^[\t ]+#/.test(after)) return;
|
|
36990
|
+
throw new FrontmatterError("unexpected text after the value", no);
|
|
36991
|
+
}
|
|
36992
|
+
function trimWhiteStart(text) {
|
|
36993
|
+
return text.slice(skipSpaces(text, 0));
|
|
36994
|
+
}
|
|
36995
|
+
function trimWhiteEnd(text) {
|
|
36996
|
+
let end = text.length;
|
|
36997
|
+
while (end > 0 && (text[end - 1] === " " || text[end - 1] === " ")) end--;
|
|
36998
|
+
return text.slice(0, end);
|
|
36999
|
+
}
|
|
37000
|
+
const NULL = /^(?:~|null|Null|NULL)$/;
|
|
37001
|
+
const TRUE = /^(?:true|True|TRUE)$/;
|
|
37002
|
+
const FALSE = /^(?:false|False|FALSE)$/;
|
|
37003
|
+
const INTEGER = /^[+-]?\d+$/;
|
|
37004
|
+
const OCTAL = /^0o[0-7]+$/;
|
|
37005
|
+
const HEXADECIMAL = /^0x[\dA-Fa-f]+$/;
|
|
37006
|
+
const FLOAT = /^[+-]?(?:\.\d+|\d+(?:\.\d*)?)(?:[Ee][+-]?\d+)?$/;
|
|
37007
|
+
const SPECIAL_FLOAT = /^(?:[+-]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
|
|
37008
|
+
function resolvePlain(text, no) {
|
|
37009
|
+
if (text === "" || NULL.test(text)) return null;
|
|
37010
|
+
if (TRUE.test(text)) return true;
|
|
37011
|
+
if (FALSE.test(text)) return false;
|
|
37012
|
+
let number;
|
|
37013
|
+
if (INTEGER.test(text) || FLOAT.test(text)) number = Number(text);
|
|
37014
|
+
else if (OCTAL.test(text)) number = Number.parseInt(text.slice(2), 8);
|
|
37015
|
+
else if (HEXADECIMAL.test(text)) number = Number.parseInt(text.slice(2), 16);
|
|
37016
|
+
else if (SPECIAL_FLOAT.test(text)) number = NaN;
|
|
37017
|
+
if (number === void 0) return text;
|
|
37018
|
+
if (!Number.isFinite(number)) throw new FrontmatterError(`"${text}" cannot be represented in JSON; quote it`, no);
|
|
37019
|
+
return number;
|
|
37020
|
+
}
|
|
37021
|
+
const SIMPLE_ESCAPES = {
|
|
37022
|
+
"0": "\0",
|
|
37023
|
+
a: "\x07",
|
|
37024
|
+
b: "\b",
|
|
37025
|
+
t: " ",
|
|
37026
|
+
" ": " ",
|
|
37027
|
+
n: "\n",
|
|
37028
|
+
v: "\v",
|
|
37029
|
+
f: "\f",
|
|
37030
|
+
r: "\r",
|
|
37031
|
+
e: "\x1B",
|
|
37032
|
+
" ": " ",
|
|
37033
|
+
"\"": "\"",
|
|
37034
|
+
"/": "/",
|
|
37035
|
+
"\\": "\\",
|
|
37036
|
+
N: "
",
|
|
37037
|
+
_: "\xA0",
|
|
37038
|
+
L: "\u2028",
|
|
37039
|
+
P: "\u2029"
|
|
37040
|
+
};
|
|
37041
|
+
const HEX_ESCAPE_WIDTH = {
|
|
37042
|
+
x: 2,
|
|
37043
|
+
u: 4,
|
|
37044
|
+
U: 8
|
|
37045
|
+
};
|
|
37046
|
+
const UNCLOSED_QUOTE = "a quoted value must close on the same line (multi-line quoted values are not supported)";
|
|
37047
|
+
function readQuoted(text, start, no) {
|
|
37048
|
+
const quote = text[start];
|
|
37049
|
+
let value = "";
|
|
37050
|
+
let index = start + 1;
|
|
37051
|
+
while (index < text.length) {
|
|
37052
|
+
const char = text[index];
|
|
37053
|
+
if (quote === "'") {
|
|
37054
|
+
if (char === "'") {
|
|
37055
|
+
if (text[index + 1] === "'") {
|
|
37056
|
+
value += "'";
|
|
37057
|
+
index += 2;
|
|
37058
|
+
continue;
|
|
37059
|
+
}
|
|
37060
|
+
return {
|
|
37061
|
+
value,
|
|
37062
|
+
end: index + 1
|
|
37063
|
+
};
|
|
37064
|
+
}
|
|
37065
|
+
value += char;
|
|
37066
|
+
index++;
|
|
37067
|
+
continue;
|
|
37068
|
+
}
|
|
37069
|
+
if (char === "\"") return {
|
|
37070
|
+
value,
|
|
37071
|
+
end: index + 1
|
|
37072
|
+
};
|
|
37073
|
+
if (char === "\\") {
|
|
37074
|
+
const escape = decodeEscape(text, index, no);
|
|
37075
|
+
value += escape.decoded;
|
|
37076
|
+
index += escape.length;
|
|
37077
|
+
continue;
|
|
37078
|
+
}
|
|
37079
|
+
value += char;
|
|
37080
|
+
index++;
|
|
37081
|
+
}
|
|
37082
|
+
throw new FrontmatterError(UNCLOSED_QUOTE, no);
|
|
37083
|
+
}
|
|
37084
|
+
function decodeEscape(text, index, no) {
|
|
37085
|
+
const code = text[index + 1];
|
|
37086
|
+
if (code === void 0) throw new FrontmatterError(UNCLOSED_QUOTE, no);
|
|
37087
|
+
const simple = SIMPLE_ESCAPES[code];
|
|
37088
|
+
if (simple !== void 0) return {
|
|
37089
|
+
decoded: simple,
|
|
37090
|
+
length: 2
|
|
37091
|
+
};
|
|
37092
|
+
const width = HEX_ESCAPE_WIDTH[code];
|
|
37093
|
+
if (width === void 0) throw new FrontmatterError(`unknown escape "\\${code}"`, no);
|
|
37094
|
+
const hex = text.slice(index + 2, index + 2 + width);
|
|
37095
|
+
if (hex.length !== width || !/^[\dA-Fa-f]+$/.test(hex) || Number.parseInt(hex, 16) > 1114111) throw new FrontmatterError(`invalid escape "\\${code}${hex}"`, no);
|
|
37096
|
+
return {
|
|
37097
|
+
decoded: String.fromCodePoint(Number.parseInt(hex, 16)),
|
|
37098
|
+
length: 2 + width
|
|
37099
|
+
};
|
|
37100
|
+
}
|
|
37101
|
+
function skipSpaces(text, index) {
|
|
37102
|
+
let position = index;
|
|
37103
|
+
while (text[position] === " " || text[position] === " ") position++;
|
|
37104
|
+
return position;
|
|
37105
|
+
}
|
|
37106
|
+
const UNCLOSED_FLOW = "a flow collection must close on the same line";
|
|
37107
|
+
function readFlow(text, start, no, depth) {
|
|
37108
|
+
assertDepth(depth, no);
|
|
37109
|
+
const open = text[start];
|
|
37110
|
+
const close = open === "[" ? "]" : "}";
|
|
37111
|
+
const list = [];
|
|
37112
|
+
const map = {};
|
|
37113
|
+
let index = skipSpaces(text, start + 1);
|
|
37114
|
+
if (text[index] === close) return {
|
|
37115
|
+
value: open === "[" ? list : map,
|
|
37116
|
+
end: index + 1
|
|
37117
|
+
};
|
|
37118
|
+
for (;;) {
|
|
37119
|
+
if (open === "[") {
|
|
37120
|
+
const item = readFlowValue(text, index, no, depth);
|
|
37121
|
+
list.push(item.value);
|
|
37122
|
+
index = skipSpaces(text, item.end);
|
|
37123
|
+
} else {
|
|
37124
|
+
const key = readFlowKey(text, index, no);
|
|
37125
|
+
const item = readFlowValue(text, key.end + 1, no, depth);
|
|
37126
|
+
if (Object.hasOwn(map, key.key)) throw new FrontmatterError(`duplicate key "${key.key}"`, no);
|
|
37127
|
+
map[key.key] = item.value;
|
|
37128
|
+
index = skipSpaces(text, item.end);
|
|
37129
|
+
}
|
|
37130
|
+
const char = text[index];
|
|
37131
|
+
if (char === ",") {
|
|
37132
|
+
index = skipSpaces(text, index + 1);
|
|
37133
|
+
if (text[index] === close) return {
|
|
37134
|
+
value: open === "[" ? list : map,
|
|
37135
|
+
end: index + 1
|
|
37136
|
+
};
|
|
37137
|
+
continue;
|
|
37138
|
+
}
|
|
37139
|
+
if (char === close) return {
|
|
37140
|
+
value: open === "[" ? list : map,
|
|
37141
|
+
end: index + 1
|
|
37142
|
+
};
|
|
37143
|
+
if (char === void 0) throw new FrontmatterError(UNCLOSED_FLOW, no);
|
|
37144
|
+
throw new FrontmatterError(`unexpected "${char}" in a flow collection`, no);
|
|
37145
|
+
}
|
|
37146
|
+
}
|
|
37147
|
+
function readFlowKey(text, start, no) {
|
|
37148
|
+
const index = skipSpaces(text, start);
|
|
37149
|
+
const char = text[index];
|
|
37150
|
+
if (char === void 0) throw new FrontmatterError(UNCLOSED_FLOW, no);
|
|
37151
|
+
if (char === "\"" || char === "'") {
|
|
37152
|
+
const quoted = readQuoted(text, index, no);
|
|
37153
|
+
const colon = skipSpaces(text, quoted.end);
|
|
37154
|
+
if (text[colon] !== ":") throw new FrontmatterError("expected \": \" after a key in a flow mapping", no);
|
|
37155
|
+
return {
|
|
37156
|
+
key: checkKey(quoted.value, no),
|
|
37157
|
+
end: colon
|
|
37158
|
+
};
|
|
37159
|
+
}
|
|
37160
|
+
if (char === "[" || char === "{") throw new FrontmatterError("complex keys (flow collections as keys) are not supported", no);
|
|
37161
|
+
assertFlowPlainStart(text, index, no);
|
|
37162
|
+
const end = scanFlowPlain(text, index);
|
|
37163
|
+
const raw = trimWhiteEnd(text.slice(index, end));
|
|
37164
|
+
const after = text[end + 1];
|
|
37165
|
+
if (raw === "" || text[end] !== ":" || after !== " " && after !== " ") throw new FrontmatterError("expected \"key: value\" in a flow mapping", no);
|
|
37166
|
+
return {
|
|
37167
|
+
key: resolveKey(raw, no),
|
|
37168
|
+
end
|
|
37169
|
+
};
|
|
37170
|
+
}
|
|
37171
|
+
const FLOW_SEPARATORS = " ,[]{}";
|
|
37172
|
+
function assertFlowPlainStart(text, index, no) {
|
|
37173
|
+
const char = text[index];
|
|
37174
|
+
if ("&*!".includes(char)) throw new FrontmatterError("anchors, aliases and tags are not supported", no);
|
|
37175
|
+
if ("|>%@`#".includes(char) || "-?:".includes(char) && FLOW_SEPARATORS.includes(text[index + 1] ?? " ")) throw new FrontmatterError(`"${char}" cannot start a plain value in a flow collection; quote it`, no);
|
|
37176
|
+
}
|
|
37177
|
+
function scanFlowPlain(text, index) {
|
|
37178
|
+
let end = index;
|
|
37179
|
+
while (end < text.length) {
|
|
37180
|
+
const char = text[end];
|
|
37181
|
+
if (",[]{}".includes(char)) break;
|
|
37182
|
+
if (char === "#" && (text[end - 1] === " " || text[end - 1] === " ")) break;
|
|
37183
|
+
if (char === ":" && FLOW_SEPARATORS.includes(text[end + 1] ?? " ")) break;
|
|
37184
|
+
end++;
|
|
37185
|
+
}
|
|
37186
|
+
return end;
|
|
37187
|
+
}
|
|
37188
|
+
function readFlowValue(text, start, no, depth) {
|
|
37189
|
+
const index = skipSpaces(text, start);
|
|
37190
|
+
const char = text[index];
|
|
37191
|
+
if (char === void 0) throw new FrontmatterError(UNCLOSED_FLOW, no);
|
|
37192
|
+
if (char === "[" || char === "{") return readFlow(text, index, no, depth + 1);
|
|
37193
|
+
if (char === "\"" || char === "'") return readQuoted(text, index, no);
|
|
37194
|
+
assertFlowPlainStart(text, index, no);
|
|
37195
|
+
const end = scanFlowPlain(text, index);
|
|
37196
|
+
if (text[end] === ":") throw new FrontmatterError("mappings inside flow collections are not supported; quote the value", no);
|
|
37197
|
+
const raw = trimWhiteEnd(text.slice(index, end));
|
|
37198
|
+
if (raw === "") throw new FrontmatterError("empty value in a flow collection", no);
|
|
37199
|
+
return {
|
|
37200
|
+
value: resolvePlain(raw, no),
|
|
37201
|
+
end
|
|
37202
|
+
};
|
|
37203
|
+
}
|
|
37204
|
+
function foldBlock(contents, folded, chomping) {
|
|
37205
|
+
let result = "";
|
|
37206
|
+
let emptyLines = 0;
|
|
37207
|
+
let didReadContent = false;
|
|
37208
|
+
let atMoreIndented = false;
|
|
37209
|
+
for (const content of contents) {
|
|
37210
|
+
if (content === "") {
|
|
37211
|
+
emptyLines++;
|
|
37212
|
+
continue;
|
|
37213
|
+
}
|
|
37214
|
+
if (folded) if (content.startsWith(" ") || content.startsWith(" ")) {
|
|
37215
|
+
atMoreIndented = true;
|
|
37216
|
+
result += "\n".repeat(didReadContent ? 1 + emptyLines : emptyLines);
|
|
37217
|
+
} else if (atMoreIndented) {
|
|
37218
|
+
atMoreIndented = false;
|
|
37219
|
+
result += "\n".repeat(emptyLines + 1);
|
|
37220
|
+
} else if (emptyLines === 0) {
|
|
37221
|
+
if (didReadContent) result += " ";
|
|
37222
|
+
} else result += "\n".repeat(emptyLines);
|
|
37223
|
+
else result += "\n".repeat(didReadContent ? 1 + emptyLines : emptyLines);
|
|
37224
|
+
result += content;
|
|
37225
|
+
didReadContent = true;
|
|
37226
|
+
emptyLines = 0;
|
|
37227
|
+
}
|
|
37228
|
+
if (chomping === "+") result += "\n".repeat(didReadContent ? 1 + emptyLines : emptyLines);
|
|
37229
|
+
else if (chomping === "" && didReadContent) result += "\n";
|
|
37230
|
+
return result;
|
|
37231
|
+
}
|
|
37232
|
+
const ENCODED_IN_SEGMENT = /[^\w!$&'()*+,.:;=@[\]|~-]/gu;
|
|
37233
|
+
function encodePathText(text) {
|
|
37234
|
+
return text.replaceAll(ENCODED_IN_SEGMENT, (char) => encodeURIComponent(char));
|
|
37235
|
+
}
|
|
37236
|
+
/** Percent-encodes one path segment as WHATWG URL does, plus `%`, `/`, `\` and the dots of `.` and `..`; throws `URIError` on a lone surrogate. */
|
|
37237
|
+
function encodeSegment(segment) {
|
|
37238
|
+
if (segment === "." || segment === "..") return segment.replaceAll(".", "%2E");
|
|
37239
|
+
return encodePathText(segment);
|
|
37240
|
+
}
|
|
37241
|
+
/** `text` without its trailing `/`s; a loop, since `/\/+$/` backtracks polynomially on a long run of `/`. */
|
|
37242
|
+
function trimTrailingSlashes(text) {
|
|
37243
|
+
let end = text.length;
|
|
37244
|
+
while (end > 0 && text.codePointAt(end - 1) === 47) end--;
|
|
37245
|
+
return text.slice(0, end);
|
|
37246
|
+
}
|
|
37247
|
+
/** `base` (`scheme://`, or a URI without a trailing slash) + `/`-joined encoded segments; an authority is encoded strictly. */
|
|
37248
|
+
function joinUri(base, segments) {
|
|
37249
|
+
if (!base.endsWith("://")) return `${trimTrailingSlashes(base)}/${segments.map((segment) => encodeSegment(segment)).join("/")}`;
|
|
37250
|
+
const [authority, ...path] = segments;
|
|
37251
|
+
if (authority === void 0) return base;
|
|
37252
|
+
return [base + encodeURIComponent(authority), ...path.map((segment) => encodeSegment(segment))].join("/");
|
|
37253
|
+
}
|
|
37254
|
+
/** WHATWG-serialised form of `uri`, or `undefined` if it does not parse. */
|
|
37255
|
+
function canonicalUri(uri) {
|
|
37256
|
+
try {
|
|
37257
|
+
return new URL(uri).href;
|
|
37258
|
+
} catch {
|
|
37259
|
+
return;
|
|
37260
|
+
}
|
|
37261
|
+
}
|
|
37262
|
+
/** True when `uri` is already in canonical form. */
|
|
37263
|
+
function isRoundTripUri(uri) {
|
|
37264
|
+
return canonicalUri(uri) === uri;
|
|
37265
|
+
}
|
|
37266
|
+
const MANIFEST_SUFFIX = "/SKILL.md";
|
|
37267
|
+
/** The skill's root directory URI (no trailing slash), or `undefined` if `skillMdUri` is not a `…/SKILL.md` URI in a named directory. */
|
|
37268
|
+
function skillRootUri(skillMdUri) {
|
|
37269
|
+
const canonical = canonicalUri(skillMdUri);
|
|
37270
|
+
if (canonical === void 0 || !canonical.endsWith(MANIFEST_SUFFIX)) return void 0;
|
|
37271
|
+
const root = canonical.slice(0, -9);
|
|
37272
|
+
return root.endsWith("/") ? void 0 : root;
|
|
37273
|
+
}
|
|
37274
|
+
function safeDecode(segment) {
|
|
37275
|
+
try {
|
|
37276
|
+
return decodeURIComponent(segment);
|
|
37277
|
+
} catch {
|
|
37278
|
+
return segment;
|
|
37279
|
+
}
|
|
37280
|
+
}
|
|
37281
|
+
/** The decoded last segment of a directory URI (the authority when the path is empty). */
|
|
37282
|
+
function lastSegmentOf(rootUri) {
|
|
37283
|
+
const url = new URL(rootUri);
|
|
37284
|
+
const path = url.pathname;
|
|
37285
|
+
return safeDecode(path === "" || path === "/" ? url.host : path.slice(path.lastIndexOf("/") + 1));
|
|
37286
|
+
}
|
|
37287
|
+
/** True when `uri` is strictly inside `rootUri` and no segment below it decodes to empty, `.`, `..`, or a name holding `/` or NUL. */
|
|
37288
|
+
function isWithin(rootUri, uri) {
|
|
37289
|
+
const canonical = canonicalUri(uri);
|
|
37290
|
+
if (canonical === void 0 || !canonical.startsWith(`${rootUri}/`)) return false;
|
|
37291
|
+
return canonical.slice(rootUri.length + 1).split("/").every((segment) => {
|
|
37292
|
+
const decoded = safeDecode(segment);
|
|
37293
|
+
return decoded !== "" && decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\0");
|
|
37294
|
+
});
|
|
37295
|
+
}
|
|
37296
|
+
function decodeOrUndefined(escapes) {
|
|
37297
|
+
try {
|
|
37298
|
+
return decodeURIComponent(escapes);
|
|
37299
|
+
} catch {
|
|
37300
|
+
return;
|
|
37301
|
+
}
|
|
37302
|
+
}
|
|
37303
|
+
function utf8Width(lead) {
|
|
37304
|
+
if (lead >= 240) return 4;
|
|
37305
|
+
if (lead >= 224) return 3;
|
|
37306
|
+
if (lead >= 192) return 2;
|
|
37307
|
+
return 1;
|
|
37308
|
+
}
|
|
37309
|
+
function keyOfEscapes(run) {
|
|
37310
|
+
let key = "";
|
|
37311
|
+
for (let at = 0; at < run.length;) {
|
|
37312
|
+
const width = 3 * utf8Width(Number.parseInt(run.slice(at + 1, at + 3), 16));
|
|
37313
|
+
const decoded = decodeOrUndefined(run.slice(at, at + width));
|
|
37314
|
+
if (decoded === void 0) {
|
|
37315
|
+
key += `%25${run.slice(at + 1, at + 3)}`;
|
|
37316
|
+
at += 3;
|
|
37317
|
+
} else {
|
|
37318
|
+
key += encodePathText(decoded);
|
|
37319
|
+
at += width;
|
|
37320
|
+
}
|
|
37321
|
+
}
|
|
37322
|
+
return key;
|
|
37323
|
+
}
|
|
37324
|
+
const ESCAPE_RUN = /((?:%[\dA-Fa-f]{2})+)/;
|
|
37325
|
+
function keyOfSegment(segment) {
|
|
37326
|
+
return segment.split(ESCAPE_RUN).map((part, index) => index % 2 === 0 ? encodePathText(part) : keyOfEscapes(part)).join("");
|
|
37327
|
+
}
|
|
37328
|
+
/** The one spelling all ways of writing a file's URI below `rootUri` share (`Q&A.md`, `Q%26A.md#x`); `undefined` if it does not parse. */
|
|
37329
|
+
function skillFileKey(rootUri, uri) {
|
|
37330
|
+
let url;
|
|
37331
|
+
try {
|
|
37332
|
+
url = new URL(uri);
|
|
37333
|
+
} catch {
|
|
37334
|
+
return;
|
|
37335
|
+
}
|
|
37336
|
+
url.hash = "";
|
|
37337
|
+
const canonical = url.href;
|
|
37338
|
+
if (canonical.includes("?") || !canonical.startsWith(`${rootUri}/`)) return canonical;
|
|
37339
|
+
return `${rootUri}/${canonical.slice(rootUri.length + 1).split("/").map((segment) => keyOfSegment(segment)).join("/")}`;
|
|
37340
|
+
}
|
|
37341
|
+
/** Code-unit order of two URIs: the one order for entries, directory children and cursors. */
|
|
37342
|
+
function compareUris(a, b) {
|
|
37343
|
+
if (a < b) return -1;
|
|
37344
|
+
if (a > b) return 1;
|
|
37345
|
+
return 0;
|
|
37346
|
+
}
|
|
37347
|
+
/** Agent Skills name rule: lowercase letters, digits and single hyphens (length checked separately). */
|
|
37348
|
+
const SKILL_NAME_PATTERN = /^[\da-z]+(?:-[\da-z]+)*$/;
|
|
37349
|
+
const isStringMap = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
37350
|
+
function countCharacters(text) {
|
|
37351
|
+
let count = 0;
|
|
37352
|
+
for (let index = 0; index < text.length; index += text.codePointAt(index) > 65535 ? 2 : 1) count++;
|
|
37353
|
+
return count;
|
|
37354
|
+
}
|
|
37355
|
+
/** Structural rules of the stable spec and Agent Skills. Empty array = valid. */
|
|
37356
|
+
function validateSkillEntry(skill) {
|
|
37357
|
+
const problems = [];
|
|
37358
|
+
const root = skillRootUri(skill.uri);
|
|
37359
|
+
if (root === void 0) return [`uri "${skill.uri}" must be a URI ending in /SKILL.md inside a named directory`];
|
|
37360
|
+
const frontmatter = skill.frontmatter;
|
|
37361
|
+
const { name, description } = skill.frontmatter;
|
|
37362
|
+
if (name.length === 0 || name.length > 64 || !SKILL_NAME_PATTERN.test(name)) problems.push(`frontmatter name "${name}" must be 1-64 lowercase letters, digits and single hyphens`);
|
|
37363
|
+
else if (lastSegmentOf(root) !== name) problems.push(`frontmatter name "${name}" must match the last path segment "${lastSegmentOf(root)}"`);
|
|
37364
|
+
if (description === "" || countCharacters(description) > 1024) problems.push("frontmatter description must be 1-1024 characters");
|
|
37365
|
+
const compatibility = frontmatter["compatibility"];
|
|
37366
|
+
if (compatibility !== void 0 && (typeof compatibility !== "string" || compatibility === "" || countCharacters(compatibility) > 500)) problems.push("frontmatter compatibility must be a string of 1-500 characters");
|
|
37367
|
+
if (frontmatter["metadata"] !== void 0 && !isStringMap(frontmatter["metadata"])) problems.push("frontmatter metadata must map strings to strings");
|
|
37368
|
+
if (skill.resources !== "dynamic") {
|
|
37369
|
+
const self = canonicalUri(skill.uri);
|
|
37370
|
+
const seen = /* @__PURE__ */ new Set();
|
|
37371
|
+
let hasSelf = false;
|
|
37372
|
+
for (const resource of skill.resources) {
|
|
37373
|
+
const canonical = canonicalUri(resource.uri);
|
|
37374
|
+
if (canonical === void 0) {
|
|
37375
|
+
problems.push(`resource "${resource.uri}" is not a valid URI`);
|
|
37376
|
+
continue;
|
|
37377
|
+
}
|
|
37378
|
+
if (/[#?]/.test(canonical)) {
|
|
37379
|
+
problems.push(`resource "${resource.uri}" must not have a query or fragment`);
|
|
37380
|
+
continue;
|
|
37381
|
+
}
|
|
37382
|
+
const key = skillFileKey(root, canonical) ?? canonical;
|
|
37383
|
+
if (seen.has(key)) problems.push(`resource "${resource.uri}" is listed more than once`);
|
|
37384
|
+
seen.add(key);
|
|
37385
|
+
if (canonical === self) hasSelf = true;
|
|
37386
|
+
else if (!isWithin(root, resource.uri)) problems.push(`resource "${resource.uri}" is outside the skill directory ${root}`);
|
|
37387
|
+
}
|
|
37388
|
+
if (!hasSelf) problems.push(`resources must include ${skill.uri}`);
|
|
37389
|
+
}
|
|
37390
|
+
return problems;
|
|
37391
|
+
}
|
|
37392
|
+
/** Schema check plus {@linkcode validateSkillEntry}, for values received from elsewhere. */
|
|
37393
|
+
function skillEntryProblems(value) {
|
|
37394
|
+
const parsed = SkillSchema.safeParse(value);
|
|
37395
|
+
if (!parsed.success) return parsed.error.issues.map((issue) => `${issue.path.join(".") || "(entry)"}: ${issue.message}`);
|
|
37396
|
+
return validateSkillEntry(parsed.data);
|
|
37397
|
+
}
|
|
37398
|
+
/** Builds an entry from a skill's files: parses `SKILL.md`, hashes every file, sorts by URI, validates. */
|
|
37399
|
+
async function buildSkillEntry(skillMdUri, files) {
|
|
37400
|
+
const manifest = files.find((file) => file.uri === skillMdUri);
|
|
37401
|
+
if (manifest === void 0) return { problems: ["SKILL.md is missing"] };
|
|
37402
|
+
const text = decodeUtf8Lossless(manifest.bytes);
|
|
37403
|
+
if (text === void 0) return { problems: ["SKILL.md is not valid UTF-8"] };
|
|
37404
|
+
let frontmatter;
|
|
37405
|
+
try {
|
|
37406
|
+
frontmatter = parseSkillMarkdown(text).frontmatter;
|
|
37407
|
+
} catch (error) {
|
|
37408
|
+
if (error instanceof FrontmatterError) return { problems: [`SKILL.md frontmatter: ${error.message}`] };
|
|
37409
|
+
throw error;
|
|
37410
|
+
}
|
|
37411
|
+
if (typeof frontmatter["name"] !== "string" || typeof frontmatter["description"] !== "string") return { problems: ["SKILL.md frontmatter must contain string \"name\" and \"description\""] };
|
|
37412
|
+
const resources = await Promise.all(files.map(async (file) => ({
|
|
37413
|
+
uri: file.uri,
|
|
37414
|
+
digest: await sha256Digest(file.bytes),
|
|
37415
|
+
size: file.bytes.byteLength
|
|
37416
|
+
})));
|
|
37417
|
+
resources.sort((a, b) => compareUris(a.uri, b.uri));
|
|
37418
|
+
const skill = {
|
|
37419
|
+
uri: skillMdUri,
|
|
37420
|
+
frontmatter,
|
|
37421
|
+
resources
|
|
37422
|
+
};
|
|
37423
|
+
return {
|
|
37424
|
+
skill,
|
|
37425
|
+
problems: validateSkillEntry(skill)
|
|
37426
|
+
};
|
|
37427
|
+
}
|
|
37428
|
+
const BY_EXTENSION = {
|
|
37429
|
+
md: "text/markdown",
|
|
37430
|
+
markdown: "text/markdown",
|
|
37431
|
+
txt: "text/plain",
|
|
37432
|
+
json: "application/json",
|
|
37433
|
+
yaml: "application/yaml",
|
|
37434
|
+
yml: "application/yaml",
|
|
37435
|
+
py: "text/x-python",
|
|
37436
|
+
sh: "application/x-sh",
|
|
37437
|
+
bash: "application/x-sh",
|
|
37438
|
+
js: "text/javascript",
|
|
37439
|
+
mjs: "text/javascript",
|
|
37440
|
+
ts: "text/typescript",
|
|
37441
|
+
html: "text/html",
|
|
37442
|
+
css: "text/css",
|
|
37443
|
+
csv: "text/csv",
|
|
37444
|
+
xml: "application/xml",
|
|
37445
|
+
svg: "image/svg+xml",
|
|
37446
|
+
png: "image/png",
|
|
37447
|
+
jpg: "image/jpeg",
|
|
37448
|
+
jpeg: "image/jpeg",
|
|
37449
|
+
gif: "image/gif",
|
|
37450
|
+
webp: "image/webp",
|
|
37451
|
+
pdf: "application/pdf"
|
|
37452
|
+
};
|
|
37453
|
+
/** MIME type from the file extension; `application/octet-stream` when unknown. */
|
|
37454
|
+
function mimeTypeFor(fileName) {
|
|
37455
|
+
const dot = fileName.lastIndexOf(".");
|
|
37456
|
+
if (dot <= 0) return "application/octet-stream";
|
|
37457
|
+
const extension = fileName.slice(dot + 1).toLowerCase();
|
|
37458
|
+
return Object.hasOwn(BY_EXTENSION, extension) ? BY_EXTENSION[extension] : "application/octet-stream";
|
|
37459
|
+
}
|
|
37460
|
+
const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
37461
|
+
const dirname$1 = (path) => {
|
|
37462
|
+
const slash = path.lastIndexOf("/");
|
|
37463
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
37464
|
+
};
|
|
37465
|
+
const errorText$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
37466
|
+
const NOT_FOLLOWED = "symlinks and other special files are not followed; copy the file instead";
|
|
37467
|
+
const isManifest = (entry) => entry.kind !== "directory" && basename(entry.path) === "SKILL.md";
|
|
37468
|
+
function append(lists, key, value) {
|
|
37469
|
+
const list = lists.get(key);
|
|
37470
|
+
if (list === void 0) lists.set(key, [value]);
|
|
37471
|
+
else list.push(value);
|
|
37472
|
+
}
|
|
37473
|
+
function representableUri(uriOf, path) {
|
|
37474
|
+
let uri;
|
|
37475
|
+
try {
|
|
37476
|
+
uri = uriOf(path);
|
|
37477
|
+
} catch (error) {
|
|
37478
|
+
if (error instanceof URIError) return void 0;
|
|
37479
|
+
throw error;
|
|
37480
|
+
}
|
|
37481
|
+
return isRoundTripUri(uri) ? uri : void 0;
|
|
37482
|
+
}
|
|
37483
|
+
async function walk(files) {
|
|
37484
|
+
const tree = [];
|
|
37485
|
+
const visit = async (dir) => {
|
|
37486
|
+
let entries;
|
|
37487
|
+
try {
|
|
37488
|
+
entries = await files.list(dir);
|
|
37489
|
+
} catch (error) {
|
|
37490
|
+
throw new SkillCatalogError([{
|
|
37491
|
+
path: dir === "" ? "." : dir,
|
|
37492
|
+
message: `cannot be listed: ${errorText$1(error)}`
|
|
37493
|
+
}]);
|
|
37494
|
+
}
|
|
37495
|
+
for (const entry of entries.toSorted((a, b) => compareUris(a.name, b.name))) {
|
|
37496
|
+
if (entry.name.startsWith(".")) continue;
|
|
37497
|
+
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
37498
|
+
tree.push({
|
|
37499
|
+
path,
|
|
37500
|
+
kind: entry.kind
|
|
37501
|
+
});
|
|
37502
|
+
if (entry.kind === "directory") await visit(path);
|
|
37503
|
+
}
|
|
37504
|
+
};
|
|
37505
|
+
await visit("");
|
|
37506
|
+
return tree;
|
|
37507
|
+
}
|
|
37508
|
+
async function readFile$1(files, path) {
|
|
37509
|
+
try {
|
|
37510
|
+
return await files.read(path);
|
|
37511
|
+
} catch (error) {
|
|
37512
|
+
throw new SkillCatalogError([{
|
|
37513
|
+
path,
|
|
37514
|
+
message: `cannot be read: ${errorText$1(error)}`
|
|
37515
|
+
}]);
|
|
37516
|
+
}
|
|
37517
|
+
}
|
|
37518
|
+
async function buildCatalogSnapshot(files, options) {
|
|
37519
|
+
const tree = await walk(files);
|
|
37520
|
+
const problems = [];
|
|
37521
|
+
const report = (warning) => {
|
|
37522
|
+
if (options.strict) problems.push(warning);
|
|
37523
|
+
else options.warn(warning);
|
|
37524
|
+
};
|
|
37525
|
+
const uriOf = (path) => joinUri("skill://", [...options.prefix, ...path.split("/")]);
|
|
37526
|
+
for (const entry of tree) {
|
|
37527
|
+
const name = basename(entry.path);
|
|
37528
|
+
if (entry.kind === "file" && name !== "SKILL.md" && name.toLowerCase() === "skill.md") options.warn({
|
|
37529
|
+
path: entry.path,
|
|
37530
|
+
message: "did you mean SKILL.md? File names are case-sensitive"
|
|
37531
|
+
});
|
|
37532
|
+
}
|
|
37533
|
+
const skillDirs = new Set(tree.filter((entry) => isManifest(entry)).map((entry) => dirname$1(entry.path)));
|
|
37534
|
+
const insideOf = /* @__PURE__ */ new Map();
|
|
37535
|
+
for (const entry of tree) {
|
|
37536
|
+
let inSkill = false;
|
|
37537
|
+
for (let dir = dirname$1(entry.path); dir !== ""; dir = dirname$1(dir)) {
|
|
37538
|
+
if (!skillDirs.has(dir)) continue;
|
|
37539
|
+
inSkill = true;
|
|
37540
|
+
append(insideOf, dir, entry);
|
|
37541
|
+
}
|
|
37542
|
+
if (!inSkill && entry.kind === "unsupported" && !isManifest(entry)) report({
|
|
37543
|
+
path: entry.path,
|
|
37544
|
+
message: `${NOT_FOLLOWED} (skipped)`
|
|
37545
|
+
});
|
|
37546
|
+
}
|
|
37547
|
+
const built = [];
|
|
37548
|
+
const hashed = /* @__PURE__ */ new Map();
|
|
37549
|
+
const manifests = tree.filter((entry) => isManifest(entry)).toSorted((a, b) => b.path.split("/").length - a.path.split("/").length);
|
|
37550
|
+
for (const entry of manifests) {
|
|
37551
|
+
const dir = dirname$1(entry.path);
|
|
37552
|
+
if (dir === "") {
|
|
37553
|
+
report({
|
|
37554
|
+
path: entry.path,
|
|
37555
|
+
message: "a SKILL.md at the root has no skill path; point loadSkills (or your adapter) at its parent folder (skipped)"
|
|
37556
|
+
});
|
|
37557
|
+
continue;
|
|
37558
|
+
}
|
|
37559
|
+
const skill = await buildOne(files, insideOf.get(dir) ?? [], dir, uriOf, options, report, hashed);
|
|
37560
|
+
if (skill !== void 0) built.push(skill);
|
|
37561
|
+
}
|
|
37562
|
+
if (problems.length > 0) throw new SkillCatalogError(problems);
|
|
37563
|
+
if (built.length === 0) options.warn({
|
|
37564
|
+
path: ".",
|
|
37565
|
+
message: "no skills found (a skill is a folder containing SKILL.md)"
|
|
37566
|
+
});
|
|
37567
|
+
return assemble(built, tree, uriOf);
|
|
37568
|
+
}
|
|
37569
|
+
async function buildOne(files, inside, dir, uriOf, options, report, hashed) {
|
|
37570
|
+
const unsupported = inside.find((entry) => entry.kind === "unsupported");
|
|
37571
|
+
if (unsupported !== void 0) {
|
|
37572
|
+
report({
|
|
37573
|
+
path: unsupported.path,
|
|
37574
|
+
message: `${NOT_FOLLOWED} (skill skipped)`
|
|
37575
|
+
});
|
|
37576
|
+
return;
|
|
37577
|
+
}
|
|
37578
|
+
const manifestPath = `${dir}/SKILL.md`;
|
|
37579
|
+
const manifest = [];
|
|
37580
|
+
const catalogFiles = [];
|
|
37581
|
+
for (const entry of inside) {
|
|
37582
|
+
const uri = representableUri(uriOf, entry.path);
|
|
37583
|
+
if (uri === void 0) {
|
|
37584
|
+
report({
|
|
37585
|
+
path: entry.path,
|
|
37586
|
+
message: "this name cannot be represented in a URI (skill skipped)"
|
|
37587
|
+
});
|
|
37588
|
+
return;
|
|
37589
|
+
}
|
|
37590
|
+
if (entry.kind !== "file") continue;
|
|
37591
|
+
const own = entry.path === manifestPath;
|
|
37592
|
+
let file = own ? void 0 : hashed.get(entry.path);
|
|
37593
|
+
if (file === void 0) {
|
|
37594
|
+
const bytes = await readFile$1(files, entry.path);
|
|
37595
|
+
if (own) manifest.push({
|
|
37596
|
+
uri,
|
|
37597
|
+
bytes
|
|
37598
|
+
});
|
|
37599
|
+
file = {
|
|
37600
|
+
digest: await sha256Digest(bytes),
|
|
37601
|
+
size: bytes.byteLength
|
|
37602
|
+
};
|
|
37603
|
+
hashed.set(entry.path, file);
|
|
37604
|
+
}
|
|
37605
|
+
catalogFiles.push({
|
|
37606
|
+
path: entry.path,
|
|
37607
|
+
uri,
|
|
37608
|
+
...file,
|
|
37609
|
+
mimeType: mimeTypeFor(basename(entry.path))
|
|
37610
|
+
});
|
|
37611
|
+
}
|
|
37612
|
+
const parsed = await buildSkillEntry(uriOf(manifestPath), manifest);
|
|
37613
|
+
const resources = catalogFiles.map(({ uri, digest, size }) => ({
|
|
37614
|
+
uri,
|
|
37615
|
+
digest,
|
|
37616
|
+
size
|
|
37617
|
+
})).toSorted((a, b) => compareUris(a.uri, b.uri));
|
|
37618
|
+
const skill = parsed.skill === void 0 ? void 0 : {
|
|
37619
|
+
...parsed.skill,
|
|
37620
|
+
resources
|
|
37621
|
+
};
|
|
37622
|
+
const problems = skill === void 0 ? parsed.problems : validateSkillEntry(skill);
|
|
37623
|
+
if (skill === void 0 || problems.length > 0) {
|
|
37624
|
+
for (const message of problems) report({
|
|
37625
|
+
path: manifestPath,
|
|
37626
|
+
message: `${message} (skill skipped)`
|
|
37627
|
+
});
|
|
37628
|
+
return;
|
|
37629
|
+
}
|
|
37630
|
+
let totalBytes = 0;
|
|
37631
|
+
for (const file of catalogFiles) totalBytes += file.size;
|
|
37632
|
+
if (catalogFiles.length > options.limits.files || totalBytes > options.limits.bytes) {
|
|
37633
|
+
const remedy = options.strict ? "Raise limits to allow it" : "Set strict: true to fail instead, or raise limits";
|
|
37634
|
+
report({
|
|
37635
|
+
path: dir,
|
|
37636
|
+
message: `${catalogFiles.length} files / ${totalBytes} bytes exceeds the limit of ${options.limits.files} files / ${options.limits.bytes} bytes; some hosts may decline it. ${remedy}`
|
|
37637
|
+
});
|
|
37638
|
+
}
|
|
37639
|
+
return {
|
|
37640
|
+
dir,
|
|
37641
|
+
skill,
|
|
37642
|
+
files: catalogFiles,
|
|
37643
|
+
manifestSize: catalogFiles.find((file) => file.path === manifestPath)?.size ?? 0,
|
|
37644
|
+
inside
|
|
37645
|
+
};
|
|
37646
|
+
}
|
|
37647
|
+
function deepFreeze(value) {
|
|
37648
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
37649
|
+
Object.freeze(value);
|
|
37650
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
37651
|
+
}
|
|
37652
|
+
return value;
|
|
37653
|
+
}
|
|
37654
|
+
function assemble(built, tree, uriOf) {
|
|
37655
|
+
const ordered = Object.freeze(built.map((item) => deepFreeze(item.skill)).toSorted((a, b) => compareUris(a.uri, b.uri)));
|
|
37656
|
+
const files = /* @__PURE__ */ new Map();
|
|
37657
|
+
const dirPaths = /* @__PURE__ */ new Set();
|
|
37658
|
+
for (const item of built) {
|
|
37659
|
+
for (const file of item.files) files.set(file.uri, file);
|
|
37660
|
+
dirPaths.add(item.dir);
|
|
37661
|
+
for (const entry of item.inside) if (entry.kind === "directory") dirPaths.add(entry.path);
|
|
37662
|
+
}
|
|
37663
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
37664
|
+
for (const entry of tree) append(childrenOf, dirname$1(entry.path), entry);
|
|
37665
|
+
const dirs = /* @__PURE__ */ new Map();
|
|
37666
|
+
for (const dirPath of dirPaths) {
|
|
37667
|
+
const children = [];
|
|
37668
|
+
for (const entry of childrenOf.get(dirPath) ?? []) {
|
|
37669
|
+
if (entry.kind === "directory") {
|
|
37670
|
+
children.push({
|
|
37671
|
+
uri: uriOf(entry.path),
|
|
37672
|
+
name: basename(entry.path),
|
|
37673
|
+
mimeType: DIRECTORY_MIME_TYPE
|
|
37674
|
+
});
|
|
37675
|
+
continue;
|
|
37676
|
+
}
|
|
37677
|
+
const file = files.get(uriOf(entry.path));
|
|
37678
|
+
if (entry.kind === "file" && file !== void 0) children.push({
|
|
37679
|
+
uri: file.uri,
|
|
37680
|
+
name: basename(entry.path),
|
|
37681
|
+
mimeType: file.mimeType,
|
|
37682
|
+
size: file.size
|
|
37683
|
+
});
|
|
37684
|
+
}
|
|
37685
|
+
children.sort((a, b) => compareUris(a.uri, b.uri));
|
|
37686
|
+
dirs.set(uriOf(dirPath), children);
|
|
37687
|
+
}
|
|
37688
|
+
const listed = built.map((item) => ({
|
|
37689
|
+
uri: item.skill.uri,
|
|
37690
|
+
name: item.skill.frontmatter.name,
|
|
37691
|
+
description: item.skill.frontmatter.description,
|
|
37692
|
+
mimeType: "text/markdown",
|
|
37693
|
+
size: item.manifestSize
|
|
37694
|
+
})).toSorted((a, b) => compareUris(a.uri, b.uri));
|
|
37695
|
+
return {
|
|
37696
|
+
ordered,
|
|
37697
|
+
skills: new Map(ordered.map((skill) => [skill.uri, skill])),
|
|
37698
|
+
files,
|
|
37699
|
+
dirs,
|
|
37700
|
+
listed
|
|
37701
|
+
};
|
|
37702
|
+
}
|
|
37703
|
+
const trustedSources = /* @__PURE__ */ new WeakSet();
|
|
37704
|
+
const CURSOR_PREFIX = "after:";
|
|
37705
|
+
function encodeCursor(uri) {
|
|
37706
|
+
return encodeBase64(encodeUtf8(CURSOR_PREFIX + uri)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
37707
|
+
}
|
|
37708
|
+
function decodeCursor(cursor) {
|
|
37709
|
+
try {
|
|
37710
|
+
const base64 = cursor.replaceAll("-", "+").replaceAll("_", "/");
|
|
37711
|
+
const decoded = decodeUtf8Lossless(decodeBase64(base64 + "=".repeat((4 - base64.length % 4) % 4)));
|
|
37712
|
+
return decoded?.startsWith(CURSOR_PREFIX) ? decoded.slice(6) : void 0;
|
|
37713
|
+
} catch {
|
|
37714
|
+
return;
|
|
37715
|
+
}
|
|
37716
|
+
}
|
|
37717
|
+
function paginate(items, cursor, pageSize, key) {
|
|
37718
|
+
let start = 0;
|
|
37719
|
+
if (cursor !== void 0) {
|
|
37720
|
+
const after = decodeCursor(cursor);
|
|
37721
|
+
if (after === void 0) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "Invalid cursor");
|
|
37722
|
+
start = items.findIndex((item) => compareUris(key(item), after) > 0);
|
|
37723
|
+
if (start === -1) start = items.length;
|
|
37724
|
+
}
|
|
37725
|
+
const end = Math.min(items.length, start + pageSize);
|
|
37726
|
+
const page = items.slice(start, end);
|
|
37727
|
+
const last = page.at(-1);
|
|
37728
|
+
return end < items.length && last !== void 0 ? {
|
|
37729
|
+
items: page,
|
|
37730
|
+
nextCursor: encodeCursor(key(last))
|
|
37731
|
+
} : { items: page };
|
|
37732
|
+
}
|
|
37733
|
+
function resolvePageSize(pageSize) {
|
|
37734
|
+
if (pageSize === void 0) return 100;
|
|
37735
|
+
if (pageSize === Number.POSITIVE_INFINITY || Number.isSafeInteger(pageSize) && pageSize > 0) return pageSize;
|
|
37736
|
+
throw new RangeError(`pageSize must be a positive integer or Infinity (got ${pageSize})`);
|
|
37737
|
+
}
|
|
37738
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
37739
|
+
function catalogSource(state, files, pageSize, warn) {
|
|
37740
|
+
const source = {
|
|
37741
|
+
async list({ cursor }) {
|
|
37742
|
+
const page = paginate(state.current.ordered, cursor, pageSize, (skill) => skill.uri);
|
|
37743
|
+
return page.nextCursor === void 0 ? { skills: page.items } : {
|
|
37744
|
+
skills: page.items,
|
|
37745
|
+
nextCursor: page.nextCursor
|
|
37746
|
+
};
|
|
37747
|
+
},
|
|
37748
|
+
async get(uri) {
|
|
37749
|
+
const canonical = canonicalUri(uri);
|
|
37750
|
+
return canonical === void 0 ? void 0 : state.current.skills.get(canonical);
|
|
37751
|
+
},
|
|
37752
|
+
read: (uri) => readChecked(state.current, files, uri, warn),
|
|
37753
|
+
async readDirectory(uri, { cursor }) {
|
|
37754
|
+
const canonical = canonicalUri(uri);
|
|
37755
|
+
const children = canonical === void 0 ? void 0 : state.current.dirs.get(canonical);
|
|
37756
|
+
if (children === void 0) return;
|
|
37757
|
+
const page = paginate(children, cursor, pageSize, (child) => child.uri);
|
|
37758
|
+
return page.nextCursor === void 0 ? { resources: page.items } : {
|
|
37759
|
+
resources: page.items,
|
|
37760
|
+
nextCursor: page.nextCursor
|
|
37761
|
+
};
|
|
37762
|
+
},
|
|
37763
|
+
async listResources() {
|
|
37764
|
+
return [...state.current.listed];
|
|
37765
|
+
}
|
|
37766
|
+
};
|
|
37767
|
+
trustedSources.add(source);
|
|
37768
|
+
return source;
|
|
37769
|
+
}
|
|
37770
|
+
async function readChecked(snapshot, files, uri, warn) {
|
|
37771
|
+
const canonical = canonicalUri(uri);
|
|
37772
|
+
const file = canonical === void 0 ? void 0 : snapshot.files.get(canonical);
|
|
37773
|
+
if (file === void 0) {
|
|
37774
|
+
if (canonical !== void 0 && snapshot.dirs.has(canonical)) throw new ResourceNotFoundError(uri, `${uri} is a directory; use resources/directory/read`);
|
|
37775
|
+
return;
|
|
37776
|
+
}
|
|
37777
|
+
let bytes;
|
|
37778
|
+
try {
|
|
37779
|
+
bytes = await files.read(file.path);
|
|
37780
|
+
} catch (error) {
|
|
37781
|
+
warn({
|
|
37782
|
+
path: file.path,
|
|
37783
|
+
message: `could not be read: ${errorText(error)}`
|
|
37784
|
+
});
|
|
37785
|
+
throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} could not be read`);
|
|
37786
|
+
}
|
|
37787
|
+
if (bytes.byteLength !== file.size || await sha256Digest(bytes) !== file.digest) {
|
|
37788
|
+
warn({
|
|
37789
|
+
path: file.path,
|
|
37790
|
+
message: "changed since the catalog was loaded; call reload()"
|
|
37791
|
+
});
|
|
37792
|
+
throw new ProtocolError(ProtocolErrorCode.InternalError, `${uri} changed since the skills catalog was loaded`);
|
|
37793
|
+
}
|
|
37794
|
+
const text = decodeUtf8Lossless(bytes);
|
|
37795
|
+
return text === void 0 ? {
|
|
37796
|
+
bytes,
|
|
37797
|
+
mimeType: file.mimeType
|
|
37798
|
+
} : {
|
|
37799
|
+
text,
|
|
37800
|
+
mimeType: file.mimeType
|
|
37801
|
+
};
|
|
37802
|
+
}
|
|
37803
|
+
function splitRelativePath(path, label) {
|
|
37804
|
+
const segments = path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
37805
|
+
if (segments.length === 0 || segments.includes("..")) throw new TypeError(`${label} must be a relative path without ".." (got ${JSON.stringify(path)})`);
|
|
37806
|
+
return segments;
|
|
37807
|
+
}
|
|
37808
|
+
function indexRecord(keys) {
|
|
37809
|
+
const dirs = /* @__PURE__ */ new Map();
|
|
37810
|
+
const files = /* @__PURE__ */ new Map();
|
|
37811
|
+
for (const key of keys) {
|
|
37812
|
+
const segments = splitRelativePath(key, "file path");
|
|
37813
|
+
let dir = "";
|
|
37814
|
+
for (const [depth, name] of segments.entries()) {
|
|
37815
|
+
let children = dirs.get(dir);
|
|
37816
|
+
if (children === void 0) {
|
|
37817
|
+
children = /* @__PURE__ */ new Map();
|
|
37818
|
+
dirs.set(dir, children);
|
|
37819
|
+
}
|
|
37820
|
+
children.set(name, {
|
|
37821
|
+
name,
|
|
37822
|
+
kind: depth < segments.length - 1 ? "directory" : "file"
|
|
37823
|
+
});
|
|
37824
|
+
dir = dir === "" ? name : `${dir}/${name}`;
|
|
37825
|
+
}
|
|
37826
|
+
files.set(dir, key);
|
|
37827
|
+
}
|
|
37828
|
+
return {
|
|
37829
|
+
dirs,
|
|
37830
|
+
files
|
|
37831
|
+
};
|
|
37832
|
+
}
|
|
37833
|
+
/**
|
|
37834
|
+
* An in-memory adapter. The record is re-read whenever a walk starts — `list('')` — so `reload()` sees later changes.
|
|
37835
|
+
* Other calls use the index from that read (built on first use if no walk has started), so their cost does not grow
|
|
37836
|
+
* with the record. File contents are always read live.
|
|
37837
|
+
*/
|
|
37838
|
+
function memorySkillFiles(files) {
|
|
37839
|
+
let index;
|
|
37840
|
+
const reindex = () => {
|
|
37841
|
+
index = indexRecord(Object.keys(files));
|
|
37842
|
+
return index;
|
|
37843
|
+
};
|
|
37844
|
+
return {
|
|
37845
|
+
async list(dir) {
|
|
37846
|
+
const children = (dir === "" ? reindex() : index ?? reindex()).dirs.get(dir);
|
|
37847
|
+
if (children !== void 0) return Array.from(children.values(), (entry) => ({ ...entry }));
|
|
37848
|
+
if (dir === "") return [];
|
|
37849
|
+
throw new Error(`no such directory: ${dir}`);
|
|
37850
|
+
},
|
|
37851
|
+
async read(path) {
|
|
37852
|
+
const key = Object.hasOwn(files, path) ? path : (index ?? reindex()).files.get(path);
|
|
37853
|
+
const content = key === void 0 ? void 0 : files[key];
|
|
37854
|
+
if (content === void 0) throw new Error(`no such file: ${path}`);
|
|
37855
|
+
return typeof content === "string" ? encodeUtf8(content) : content;
|
|
37856
|
+
}
|
|
37857
|
+
};
|
|
37858
|
+
}
|
|
37859
|
+
const SCHEME_URI = /^[a-z][\d+.a-z-]*:\/\//i;
|
|
37860
|
+
function describeEntry(value) {
|
|
37861
|
+
const uri = value?.uri;
|
|
37862
|
+
return typeof uri === "string" ? uri : "(entry without a uri)";
|
|
37863
|
+
}
|
|
37864
|
+
function toContents(uri, result) {
|
|
37865
|
+
const mimeType = result.mimeType === void 0 ? {} : { mimeType: result.mimeType };
|
|
37866
|
+
return "text" in result ? {
|
|
37867
|
+
uri,
|
|
37868
|
+
...mimeType,
|
|
37869
|
+
text: result.text
|
|
37870
|
+
} : {
|
|
37871
|
+
uri,
|
|
37872
|
+
...mimeType,
|
|
37873
|
+
blob: encodeBase64(result.bytes)
|
|
37874
|
+
};
|
|
37875
|
+
}
|
|
37876
|
+
const skillsServers = /* @__PURE__ */ new WeakSet();
|
|
37877
|
+
const afterAttach = /* @__PURE__ */ new WeakMap();
|
|
37878
|
+
const ONE_PER_SERVER = "another skills extension is already attached to this server; attach one per server (to serve several folders or sources, combine them behind one SkillFiles adapter or SkillSource)";
|
|
37879
|
+
/**
|
|
37880
|
+
* The MCP Skills extension (SEP-2640) for one catalog of skills. One instance
|
|
37881
|
+
* serves any number of servers: call {@linkcode SkillsExtension.attach} on each
|
|
37882
|
+
* (before connecting). Build it from a {@linkcode SkillSource}, or with
|
|
37883
|
+
* `SkillsExtension.load(files)` from storage.
|
|
37884
|
+
*/
|
|
37885
|
+
var SkillsExtension = class {
|
|
37886
|
+
id = SKILLS_EXTENSION_ID;
|
|
37887
|
+
#source;
|
|
37888
|
+
#trusted;
|
|
37889
|
+
#baseRoot;
|
|
37890
|
+
#cacheHint;
|
|
37891
|
+
#cache;
|
|
37892
|
+
#strict;
|
|
37893
|
+
#warn;
|
|
37894
|
+
#attached = /* @__PURE__ */ new WeakSet();
|
|
37895
|
+
/**
|
|
37896
|
+
* Builds a catalog from storage through a {@linkcode SkillFiles} adapter
|
|
37897
|
+
* (`memorySkillFiles`, `nodeSkillFiles`, or your own).
|
|
37898
|
+
*/
|
|
37899
|
+
static load(files, options) {
|
|
37900
|
+
return SkillCatalog.load(files, options);
|
|
37901
|
+
}
|
|
37902
|
+
constructor(options) {
|
|
37903
|
+
if (options.cacheHint !== void 0) assertValidCacheHint(options.cacheHint, "skills cacheHint");
|
|
37904
|
+
const baseUri = options.baseUri ?? "skill://";
|
|
37905
|
+
const canonical = SCHEME_URI.test(baseUri) ? canonicalUri(baseUri) : void 0;
|
|
37906
|
+
if (canonical === void 0) throw new TypeError(`baseUri must be a "scheme://…" URI (got ${JSON.stringify(baseUri)})`);
|
|
37907
|
+
this.#baseRoot = canonical.endsWith("://") ? canonical : `${trimTrailingSlashes(canonical)}/`;
|
|
37908
|
+
this.#source = options.source;
|
|
37909
|
+
this.#trusted = trustedSources.has(options.source);
|
|
37910
|
+
this.#cacheHint = options.cacheHint;
|
|
37911
|
+
this.#cache = {
|
|
37912
|
+
ttlMs: options.cacheHint?.ttlMs ?? 0,
|
|
37913
|
+
cacheScope: options.cacheHint?.cacheScope ?? "private"
|
|
37914
|
+
};
|
|
37915
|
+
this.#strict = options.strict ?? false;
|
|
37916
|
+
this.#warn = options.onWarning ?? defaultWarn;
|
|
37917
|
+
}
|
|
37918
|
+
/**
|
|
37919
|
+
* Serves these skills on `mcp`: registers the skill resource template and
|
|
37920
|
+
* the skills methods, then declares the capability. Must run before
|
|
37921
|
+
* connecting. Calling it again for the same server does nothing. One
|
|
37922
|
+
* skills extension per server: attaching another one throws and leaves the
|
|
37923
|
+
* server as it was, as does attaching to a server that already handles one
|
|
37924
|
+
* of the skills methods.
|
|
37925
|
+
*
|
|
37926
|
+
* @experimental How an extension is wired to a server may change in a minor release.
|
|
37927
|
+
*/
|
|
37928
|
+
attach(mcp) {
|
|
37929
|
+
if (this.#attached.has(mcp)) return;
|
|
37930
|
+
if (mcp.isConnected()) throw new Error("attach skills before connecting the server");
|
|
37931
|
+
if (skillsServers.has(mcp)) throw new Error(ONE_PER_SERVER);
|
|
37932
|
+
const server = mcp.server;
|
|
37933
|
+
const directoryRead = this.#source.readDirectory !== void 0;
|
|
37934
|
+
const list = this.#source.listResources === void 0 ? void 0 : (ctx) => this.#listResources(ctx);
|
|
37935
|
+
server.assertCanSetRequestHandler(SKILLS_LIST_METHOD);
|
|
37936
|
+
server.assertCanSetRequestHandler(SKILLS_GET_METHOD);
|
|
37937
|
+
if (directoryRead) server.assertCanSetRequestHandler(RESOURCES_DIRECTORY_READ_METHOD);
|
|
37938
|
+
mcp.registerResource(SKILLS_EXTENSION_ID, new ResourceTemplate(`${this.#baseRoot}{+path}`, { list }), this.#cacheHint === void 0 ? {} : { cacheHint: this.#cacheHint }, (uri, _variables, ctx) => this.#read(uri, ctx));
|
|
37939
|
+
server.setRequestHandler(SKILLS_LIST_METHOD, { params: PaginatedRequestParamsSchema }, (params, ctx) => this.#list(server, params.cursor, ctx));
|
|
37940
|
+
server.setRequestHandler(SKILLS_GET_METHOD, { params: GetSkillRequestParamsSchema }, (params, ctx) => this.#get(server, params.uri, ctx));
|
|
37941
|
+
if (directoryRead) server.setRequestHandler(RESOURCES_DIRECTORY_READ_METHOD, { params: ReadResourceDirectoryRequestParamsSchema }, (params, ctx) => this.#readDirectory(server, params.uri, params.cursor, ctx));
|
|
37942
|
+
server.registerCapabilities({ extensions: { [SKILLS_EXTENSION_ID]: directoryRead ? { directoryRead: true } : {} } });
|
|
37943
|
+
this.#attached.add(mcp);
|
|
37944
|
+
skillsServers.add(mcp);
|
|
37945
|
+
afterAttach.get(this)?.(mcp);
|
|
37946
|
+
}
|
|
37947
|
+
#cacheFields(server) {
|
|
37948
|
+
return isModernProtocolVersion(server.getNegotiatedProtocolVersion() ?? "") ? { ...this.#cache } : {};
|
|
37949
|
+
}
|
|
37950
|
+
#keepValid(skills) {
|
|
37951
|
+
if (this.#trusted) return [...skills];
|
|
37952
|
+
const kept = [];
|
|
37953
|
+
for (const candidate of skills) {
|
|
37954
|
+
const problems = skillEntryProblems(candidate);
|
|
37955
|
+
if (problems.length === 0) {
|
|
37956
|
+
kept.push(candidate);
|
|
37957
|
+
continue;
|
|
37958
|
+
}
|
|
37959
|
+
const message = `invalid skill entry from the source: ${problems.join("; ")}`;
|
|
37960
|
+
if (this.#strict) throw new ProtocolError(ProtocolErrorCode.InternalError, `${describeEntry(candidate)}: ${message}`);
|
|
37961
|
+
this.#warn({
|
|
37962
|
+
path: describeEntry(candidate),
|
|
37963
|
+
message: `${message} (dropped from skills/list)`
|
|
37964
|
+
});
|
|
37965
|
+
}
|
|
37966
|
+
return kept;
|
|
37967
|
+
}
|
|
37968
|
+
async #list(server, cursor, ctx) {
|
|
37969
|
+
const page = await this.#source.list(cursor === void 0 ? {} : { cursor }, ctx);
|
|
37970
|
+
return {
|
|
37971
|
+
skills: this.#keepValid(page.skills),
|
|
37972
|
+
...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor },
|
|
37973
|
+
...this.#cacheFields(server)
|
|
37974
|
+
};
|
|
37975
|
+
}
|
|
37976
|
+
async #get(server, uri, ctx) {
|
|
37977
|
+
const skill = await this.#source.get(uri, ctx);
|
|
37978
|
+
if (skill === void 0) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No skill is served at ${uri}`);
|
|
37979
|
+
if (!this.#trusted) {
|
|
37980
|
+
const problems = skillEntryProblems(skill);
|
|
37981
|
+
if (problems.length > 0) {
|
|
37982
|
+
const message = `invalid skill entry from the source: ${problems.join("; ")}`;
|
|
37983
|
+
this.#warn({
|
|
37984
|
+
path: uri,
|
|
37985
|
+
message
|
|
37986
|
+
});
|
|
37987
|
+
throw new ProtocolError(ProtocolErrorCode.InternalError, message);
|
|
37988
|
+
}
|
|
37989
|
+
}
|
|
37990
|
+
if (canonicalUri(skill.uri) !== canonicalUri(uri)) throw new ProtocolError(ProtocolErrorCode.InternalError, `the skill source returned ${skill.uri} for ${uri}`);
|
|
37991
|
+
return {
|
|
37992
|
+
skill,
|
|
37993
|
+
...this.#cacheFields(server)
|
|
37994
|
+
};
|
|
37995
|
+
}
|
|
37996
|
+
async #read(uri, ctx) {
|
|
37997
|
+
const result = await this.#source.read(uri.href, ctx);
|
|
37998
|
+
if (result === void 0) throw new ResourceNotFoundError(uri.href);
|
|
37999
|
+
return { contents: [toContents(uri.href, result)] };
|
|
38000
|
+
}
|
|
38001
|
+
async #readDirectory(server, uri, cursor, ctx) {
|
|
38002
|
+
const page = await this.#source.readDirectory(uri, cursor === void 0 ? {} : { cursor }, ctx);
|
|
38003
|
+
if (page === void 0) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `${uri} is not a directory resource`);
|
|
38004
|
+
return {
|
|
38005
|
+
resources: page.resources,
|
|
38006
|
+
...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor },
|
|
38007
|
+
...this.#cacheFields(server)
|
|
38008
|
+
};
|
|
38009
|
+
}
|
|
38010
|
+
async #listResources(ctx) {
|
|
38011
|
+
const resources = await this.#source.listResources(ctx);
|
|
38012
|
+
const kept = [];
|
|
38013
|
+
for (const resource of resources) if (canonicalUri(resource.uri)?.startsWith(this.#baseRoot)) kept.push(resource);
|
|
38014
|
+
else this.#warn({
|
|
38015
|
+
path: resource.uri,
|
|
38016
|
+
message: `listResources returned a URI outside ${this.#baseRoot}; dropped from resources/list`
|
|
38017
|
+
});
|
|
38018
|
+
return { resources: kept };
|
|
38019
|
+
}
|
|
38020
|
+
};
|
|
38021
|
+
const PRUNE_THRESHOLD = 1024;
|
|
38022
|
+
function resolveLimits(limits) {
|
|
38023
|
+
const resolved = {
|
|
38024
|
+
files: limits?.files ?? SKILL_LIMITS.files,
|
|
38025
|
+
bytes: limits?.bytes ?? SKILL_LIMITS.bytes
|
|
38026
|
+
};
|
|
38027
|
+
for (const [name, value] of Object.entries(resolved)) if (value !== Number.POSITIVE_INFINITY && !(Number.isSafeInteger(value) && value > 0)) throw new RangeError(`limits.${name} must be a positive integer or Infinity (got ${value})`);
|
|
38028
|
+
return resolved;
|
|
38029
|
+
}
|
|
38030
|
+
function listingKey(snapshot) {
|
|
38031
|
+
return JSON.stringify(snapshot.listed.map((resource) => [
|
|
38032
|
+
resource.uri,
|
|
38033
|
+
resource.name,
|
|
38034
|
+
resource.description
|
|
38035
|
+
]));
|
|
38036
|
+
}
|
|
38037
|
+
function diffSnapshots(previous, next) {
|
|
38038
|
+
const same = (uri) => JSON.stringify(previous.skills.get(uri)) === JSON.stringify(next.skills.get(uri));
|
|
38039
|
+
return {
|
|
38040
|
+
added: [...next.skills.keys()].filter((uri) => !previous.skills.has(uri)),
|
|
38041
|
+
removed: [...previous.skills.keys()].filter((uri) => !next.skills.has(uri)),
|
|
38042
|
+
changed: [...next.skills.keys()].filter((uri) => previous.skills.has(uri) && !same(uri))
|
|
38043
|
+
};
|
|
38044
|
+
}
|
|
38045
|
+
/**
|
|
38046
|
+
* A catalog the SDK built from storage. It holds metadata only: every file
|
|
38047
|
+
* read goes through the adapter and is checked against the published entry.
|
|
38048
|
+
*/
|
|
38049
|
+
var SkillCatalog = class SkillCatalog extends SkillsExtension {
|
|
38050
|
+
#state;
|
|
38051
|
+
#files;
|
|
38052
|
+
#build;
|
|
38053
|
+
#servers = /* @__PURE__ */ new Set();
|
|
38054
|
+
#pruneAt = PRUNE_THRESHOLD;
|
|
38055
|
+
#queue = Promise.resolve();
|
|
38056
|
+
constructor(init) {
|
|
38057
|
+
super(init.sourceOptions);
|
|
38058
|
+
this.#state = init.state;
|
|
38059
|
+
this.#files = init.files;
|
|
38060
|
+
this.#build = init.build;
|
|
38061
|
+
afterAttach.set(this, (mcp) => this.#track(mcp));
|
|
38062
|
+
}
|
|
38063
|
+
static async load(files, options = {}) {
|
|
38064
|
+
const pageSize = resolvePageSize(options.pageSize);
|
|
38065
|
+
const build = {
|
|
38066
|
+
prefix: options.prefix === void 0 ? [] : splitRelativePath(options.prefix, "prefix"),
|
|
38067
|
+
limits: resolveLimits(options.limits),
|
|
38068
|
+
strict: options.strict ?? false,
|
|
38069
|
+
warn: options.onWarning ?? defaultWarn
|
|
38070
|
+
};
|
|
38071
|
+
const state = { current: await buildCatalogSnapshot(files, build) };
|
|
38072
|
+
return new SkillCatalog({
|
|
38073
|
+
state,
|
|
38074
|
+
files,
|
|
38075
|
+
build,
|
|
38076
|
+
sourceOptions: {
|
|
38077
|
+
source: catalogSource(state, files, pageSize, build.warn),
|
|
38078
|
+
baseUri: build.prefix.length === 0 ? "skill://" : joinUri("skill://", build.prefix),
|
|
38079
|
+
cacheHint: options.cacheHint,
|
|
38080
|
+
strict: options.strict,
|
|
38081
|
+
onWarning: build.warn
|
|
38082
|
+
}
|
|
38083
|
+
});
|
|
38084
|
+
}
|
|
38085
|
+
/** The skills this catalog serves, sorted by URI. Frozen (deeply): they are exactly what clients receive. */
|
|
38086
|
+
get entries() {
|
|
38087
|
+
return this.#state.current.ordered;
|
|
38088
|
+
}
|
|
38089
|
+
/**
|
|
38090
|
+
* Scans the storage again and swaps the new catalog in at once. All or
|
|
38091
|
+
* nothing: on failure the old catalog keeps serving. Overlapping calls run
|
|
38092
|
+
* one after another.
|
|
38093
|
+
*
|
|
38094
|
+
* When skills are added or removed, or a `SKILL.md`'s name or description
|
|
38095
|
+
* changes, the result has `listChanged: true` and `resources/list_changed`
|
|
38096
|
+
* goes out through every attached server that is still connected (stdio,
|
|
38097
|
+
* stateful 2025-11-25 HTTP sessions). Servers that `createMcpHandler` builds per
|
|
38098
|
+
* request are closed by then, so publish the change on the handler:
|
|
38099
|
+
* `if (changes.listChanged) handler.notify.resourcesChanged()`. Where the
|
|
38100
|
+
* runtime has no `WeakRef` (Cloudflare Workers with a compatibility date
|
|
38101
|
+
* before 2025-05-05), attached servers are not tracked and nothing is sent.
|
|
38102
|
+
*/
|
|
38103
|
+
reload() {
|
|
38104
|
+
const run = this.#queue.then(() => this.#reloadNow());
|
|
38105
|
+
this.#queue = run.then(() => {}, () => {});
|
|
38106
|
+
return run;
|
|
38107
|
+
}
|
|
38108
|
+
#track(mcp) {
|
|
38109
|
+
if (typeof WeakRef !== "function") return;
|
|
38110
|
+
if (this.#servers.size >= this.#pruneAt) {
|
|
38111
|
+
for (const reference of this.#servers) if (reference.deref() === void 0) this.#servers.delete(reference);
|
|
38112
|
+
this.#pruneAt = Math.max(PRUNE_THRESHOLD, 2 * this.#servers.size);
|
|
38113
|
+
}
|
|
38114
|
+
this.#servers.add(new WeakRef(mcp));
|
|
38115
|
+
}
|
|
38116
|
+
async #reloadNow() {
|
|
38117
|
+
const previous = this.#state.current;
|
|
38118
|
+
const next = await buildCatalogSnapshot(this.#files, this.#build);
|
|
38119
|
+
this.#state.current = next;
|
|
38120
|
+
const listChanged = listingKey(previous) !== listingKey(next);
|
|
38121
|
+
if (listChanged) this.#notify();
|
|
38122
|
+
return {
|
|
38123
|
+
...diffSnapshots(previous, next),
|
|
38124
|
+
listChanged
|
|
38125
|
+
};
|
|
38126
|
+
}
|
|
38127
|
+
#notify() {
|
|
38128
|
+
for (const reference of this.#servers) {
|
|
38129
|
+
const mcp = reference.deref();
|
|
38130
|
+
if (mcp === void 0) {
|
|
38131
|
+
this.#servers.delete(reference);
|
|
38132
|
+
continue;
|
|
38133
|
+
}
|
|
38134
|
+
if (!mcp.isConnected()) continue;
|
|
38135
|
+
const sessionId = mcp.server.transport?.sessionId;
|
|
38136
|
+
try {
|
|
38137
|
+
Promise.resolve(mcp.server.sendResourceListChanged()).catch((error) => this.#notifyFailed(sessionId, error));
|
|
38138
|
+
} catch (error) {
|
|
38139
|
+
this.#notifyFailed(sessionId, error);
|
|
38140
|
+
}
|
|
38141
|
+
}
|
|
38142
|
+
}
|
|
38143
|
+
#notifyFailed(sessionId, error) {
|
|
38144
|
+
const to = sessionId === void 0 ? "" : ` to session ${sessionId}`;
|
|
38145
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
38146
|
+
try {
|
|
38147
|
+
this.#build.warn({
|
|
38148
|
+
path: "resources/list_changed",
|
|
38149
|
+
message: `could not be sent${to}: ${reason}`
|
|
38150
|
+
});
|
|
38151
|
+
} catch {}
|
|
38152
|
+
}
|
|
38153
|
+
};
|
|
38154
|
+
//#endregion
|
|
38155
|
+
//#region node_modules/.pnpm/@modelcontextprotocol+server@2.4.0/node_modules/@modelcontextprotocol/server/dist/ext/skills/index.mjs
|
|
38156
|
+
/**
|
|
38157
|
+
* Builds a valid entry — digests, sizes, frontmatter, rule checks — from one
|
|
38158
|
+
* skill's files. Keys are paths under the skill's folder (`SKILL.md`,
|
|
38159
|
+
* `references/GUIDE.md`). For `SkillSource` authors who publish digests.
|
|
38160
|
+
*/
|
|
38161
|
+
async function skillEntry(skillPath, files, options = {}) {
|
|
38162
|
+
const segments = splitRelativePath(skillPath, "skillPath");
|
|
38163
|
+
const baseUri = options.baseUri ?? "skill://";
|
|
38164
|
+
const inputs = Object.entries(files).map(([path, content]) => ({
|
|
38165
|
+
uri: joinUri(baseUri, [...segments, ...splitRelativePath(path, "file path")]),
|
|
38166
|
+
bytes: typeof content === "string" ? encodeUtf8(content) : content
|
|
38167
|
+
}));
|
|
38168
|
+
const { skill, problems } = await buildSkillEntry(joinUri(baseUri, [...segments, "SKILL.md"]), inputs);
|
|
38169
|
+
if (skill === void 0 || problems.length > 0) throw new SkillCatalogError(problems.map((message) => ({
|
|
38170
|
+
path: skillPath,
|
|
38171
|
+
message
|
|
38172
|
+
})));
|
|
38173
|
+
return skill;
|
|
38174
|
+
}
|
|
38175
|
+
//#endregion
|
|
38176
|
+
//#region src/customization/model.ts
|
|
38177
|
+
/** `global` reaches every session; a namespace's own reaches the sessions that expose it. */
|
|
38178
|
+
const INSTRUCTION_SCOPES = ["global", ...Namespace.options];
|
|
38179
|
+
/** Per scope, and for all scopes together: what one session can receive at most. */
|
|
38180
|
+
const INSTRUCTION_MAX_CHARS = 4e3;
|
|
38181
|
+
const INSTRUCTIONS_TOTAL_MAX_CHARS = 12e3;
|
|
38182
|
+
const PROMPT_MAX_CHARS = 32e3;
|
|
38183
|
+
const SKILL_FILE_MAX_CHARS = 64e3;
|
|
38184
|
+
const SKILL_MAX_FILES = 32;
|
|
38185
|
+
const SKILL_TOTAL_MAX_CHARS = 256e3;
|
|
38186
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
38187
|
+
const NAME_MAX = 64;
|
|
38188
|
+
const PATH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
38189
|
+
const PATH_MAX_DEPTH = 4;
|
|
38190
|
+
const SKILL_ENTRY_FILE = "SKILL.md";
|
|
38191
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
38192
|
+
const SINGLE_QUOTED = /^'(.*)'$/;
|
|
38193
|
+
const LINE_BREAK = /\r?\n/;
|
|
38194
|
+
const unquote = (value) => {
|
|
38195
|
+
if (value.startsWith("\"")) try {
|
|
38196
|
+
return JSON.parse(value);
|
|
38197
|
+
} catch {
|
|
38198
|
+
return value;
|
|
38199
|
+
}
|
|
38200
|
+
const single = SINGLE_QUOTED.exec(value);
|
|
38201
|
+
return single ? (single[1] ?? "").replaceAll("''", "'") : value;
|
|
38202
|
+
};
|
|
38203
|
+
/**
|
|
38204
|
+
* The `key: value` lines of a leading `---` block and the body after it. Only
|
|
38205
|
+
* single-line values: a YAML block scalar reads as its indicator, and a skill
|
|
38206
|
+
* using one is refused at write time rather than served half-parsed.
|
|
38207
|
+
*/
|
|
38208
|
+
const parseFrontmatter = (text) => {
|
|
38209
|
+
const match = FRONTMATTER.exec(text);
|
|
38210
|
+
if (!match) return {
|
|
38211
|
+
fields: /* @__PURE__ */ new Map(),
|
|
38212
|
+
body: text
|
|
38213
|
+
};
|
|
38214
|
+
const fields = /* @__PURE__ */ new Map();
|
|
38215
|
+
for (const line of (match[1] ?? "").split(LINE_BREAK)) {
|
|
38216
|
+
const colon = line.indexOf(":");
|
|
38217
|
+
if (colon <= 0 || line.startsWith(" ")) continue;
|
|
38218
|
+
const value = line.slice(colon + 1).trim();
|
|
38219
|
+
fields.set(line.slice(0, colon).trim(), unquote(value));
|
|
38220
|
+
}
|
|
38221
|
+
return {
|
|
38222
|
+
fields,
|
|
38223
|
+
body: text.slice(match[0].length)
|
|
38224
|
+
};
|
|
38225
|
+
};
|
|
38226
|
+
const yamlValue = (value) => JSON.stringify(value);
|
|
38227
|
+
const isScope = (value) => INSTRUCTION_SCOPES.includes(value);
|
|
38228
|
+
const MARKDOWN = ".md";
|
|
38229
|
+
/** The scope a top-level `instructions/<scope>.md` holds, if it is one. */
|
|
38230
|
+
const instructionScopeOf = (name) => {
|
|
38231
|
+
const scope = name.endsWith(MARKDOWN) ? name.slice(0, -3) : "";
|
|
38232
|
+
return isScope(scope) ? scope : void 0;
|
|
38233
|
+
};
|
|
38234
|
+
const parsePrompt = (name, text, author) => {
|
|
38235
|
+
const promptName = name.endsWith(MARKDOWN) ? name.slice(0, -3) : "";
|
|
38236
|
+
const { fields, body } = parseFrontmatter(text);
|
|
38237
|
+
const description = fields.get("description");
|
|
38238
|
+
if (!(NAME.test(promptName) && description)) return void 0;
|
|
38239
|
+
return {
|
|
38240
|
+
name: promptName,
|
|
38241
|
+
title: fields.get("title"),
|
|
38242
|
+
description,
|
|
38243
|
+
text: body.trim(),
|
|
38244
|
+
author
|
|
38245
|
+
};
|
|
38246
|
+
};
|
|
38247
|
+
/** Turns the stored tree into entries; anything unexpected is listed, not served. */
|
|
38248
|
+
const parseCustomizations = (tree) => {
|
|
38249
|
+
const instructions = /* @__PURE__ */ new Map();
|
|
38250
|
+
const prompts = /* @__PURE__ */ new Map();
|
|
38251
|
+
const skillFiles = /* @__PURE__ */ new Map();
|
|
38252
|
+
const ignored = [];
|
|
38253
|
+
const takers = {
|
|
38254
|
+
instructions: (name, rest, text, author) => {
|
|
38255
|
+
const scope = rest.length === 0 ? instructionScopeOf(name) : void 0;
|
|
38256
|
+
if (!scope || text.trim() === "") return false;
|
|
38257
|
+
instructions.set(scope, {
|
|
38258
|
+
scope,
|
|
38259
|
+
text: text.trim(),
|
|
38260
|
+
author
|
|
38261
|
+
});
|
|
38262
|
+
return true;
|
|
38263
|
+
},
|
|
38264
|
+
prompts: (name, rest, text, author) => {
|
|
38265
|
+
const prompt = rest.length === 0 ? parsePrompt(name, text, author) : void 0;
|
|
38266
|
+
if (!prompt) return false;
|
|
38267
|
+
prompts.set(prompt.name, prompt);
|
|
38268
|
+
return true;
|
|
38269
|
+
},
|
|
38270
|
+
skills: (name, rest, text) => {
|
|
38271
|
+
if (!NAME.test(name) || rest.length === 0) return false;
|
|
38272
|
+
const files = skillFiles.get(name) ?? /* @__PURE__ */ new Map();
|
|
38273
|
+
files.set(rest.join("/"), text);
|
|
38274
|
+
skillFiles.set(name, files);
|
|
38275
|
+
return true;
|
|
38276
|
+
}
|
|
38277
|
+
};
|
|
38278
|
+
for (const [path, text] of tree.files) {
|
|
38279
|
+
const [kind = "", name = "", ...rest] = path.split("/");
|
|
38280
|
+
if (!(Object.hasOwn(takers, kind) && takers[kind]?.(name, rest, text, tree.authors.get(path)))) ignored.push(path);
|
|
38281
|
+
}
|
|
38282
|
+
const skills = /* @__PURE__ */ new Map();
|
|
38283
|
+
for (const [name, files] of skillFiles) {
|
|
38284
|
+
const entry = files.get(SKILL_ENTRY_FILE);
|
|
38285
|
+
const description = entry ? parseFrontmatter(entry).fields.get("description") : void 0;
|
|
38286
|
+
if (!description || prompts.has(name)) {
|
|
38287
|
+
ignored.push(...[...files.keys()].map((file) => `skills/${name}/${file}`));
|
|
38288
|
+
continue;
|
|
38289
|
+
}
|
|
38290
|
+
skills.set(name, {
|
|
38291
|
+
name,
|
|
38292
|
+
description,
|
|
38293
|
+
files,
|
|
38294
|
+
author: tree.authors.get(`skills/${name}/${SKILL_ENTRY_FILE}`)
|
|
38295
|
+
});
|
|
38296
|
+
}
|
|
38297
|
+
return {
|
|
38298
|
+
instructions,
|
|
38299
|
+
prompts,
|
|
38300
|
+
skills,
|
|
38301
|
+
ignored
|
|
38302
|
+
};
|
|
38303
|
+
};
|
|
38304
|
+
const isCustomizationError = (err) => err instanceof Error && err.name === "CustomizationError";
|
|
38305
|
+
/** A refused edit names the rule it breaks, so the model can fix the call. */
|
|
38306
|
+
const customizationError = (message) => Object.assign(new Error(message), { name: "CustomizationError" });
|
|
38307
|
+
const fail = (message) => {
|
|
38308
|
+
throw customizationError(message);
|
|
38309
|
+
};
|
|
38310
|
+
const checkName = (name, what) => {
|
|
38311
|
+
if (name.length > NAME_MAX || !NAME.test(name)) fail(`Invalid ${what} name "${name}": use lowercase letters, digits and single hyphens (e.g. draft-reply), at most ${NAME_MAX} characters.`);
|
|
38312
|
+
};
|
|
38313
|
+
const checkLength = (text, max, what) => {
|
|
38314
|
+
if (text.length > max) fail(`${what} is ${text.length} characters long; the limit is ${max}.`);
|
|
38315
|
+
};
|
|
38316
|
+
const checkSkillPath = (path) => {
|
|
38317
|
+
const segments = path.split("/");
|
|
38318
|
+
if (segments.length > PATH_MAX_DEPTH || !segments.every((s) => PATH_SEGMENT.test(s))) fail(`Invalid skill file path "${path}": use relative, /-separated names of letters, digits, dots, hyphens and underscores (e.g. references/lexicon.md), at most ${PATH_MAX_DEPTH} levels deep.`);
|
|
38319
|
+
};
|
|
38320
|
+
const instructionsTotal = (current, scope, text) => [...current.instructions.values()].filter((entry) => entry.scope !== scope).reduce((sum, entry) => sum + entry.text.length, text.length);
|
|
38321
|
+
const planInstructions = (current, scope, text) => {
|
|
38322
|
+
const path = `instructions/${scope}.md`;
|
|
38323
|
+
if (text === null) {
|
|
38324
|
+
if (!current.instructions.has(scope)) fail(`No instructions are set for "${scope}".`);
|
|
38325
|
+
return [{
|
|
38326
|
+
path,
|
|
38327
|
+
content: null
|
|
38328
|
+
}];
|
|
38329
|
+
}
|
|
38330
|
+
const trimmed = text.trim();
|
|
38331
|
+
if (trimmed === "") fail("The instructions are empty; delete them instead.");
|
|
38332
|
+
checkLength(trimmed, INSTRUCTION_MAX_CHARS, `The "${scope}" instructions`);
|
|
38333
|
+
const total = instructionsTotal(current, scope, trimmed);
|
|
38334
|
+
if (total > 12e3) fail(`All instructions together would be ${total} characters long; the limit is ${INSTRUCTIONS_TOTAL_MAX_CHARS}, since every session receives them. Shorten them, or move reference material into a skill.`);
|
|
38335
|
+
return [{
|
|
38336
|
+
path,
|
|
38337
|
+
content: `${trimmed}\n`
|
|
38338
|
+
}];
|
|
38339
|
+
};
|
|
38340
|
+
const planPrompt = (current, name, prompt) => {
|
|
38341
|
+
checkName(name, "prompt");
|
|
38342
|
+
const path = `prompts/${name}.md`;
|
|
38343
|
+
if (prompt === null) {
|
|
38344
|
+
if (!current.prompts.has(name)) fail(`No prompt is named "${name}".`);
|
|
38345
|
+
return [{
|
|
38346
|
+
path,
|
|
38347
|
+
content: null
|
|
38348
|
+
}];
|
|
38349
|
+
}
|
|
38350
|
+
if (current.skills.has(name)) fail(`A skill is already named "${name}", and every skill is served as a prompt of its name.`);
|
|
38351
|
+
const description = prompt.description.trim();
|
|
38352
|
+
const text = prompt.text.trim();
|
|
38353
|
+
if (description === "" || description.includes("\n")) fail("A prompt needs a one-line description: it is what clients show in their prompt menu.");
|
|
38354
|
+
if (text === "") fail("The prompt text is empty.");
|
|
38355
|
+
checkLength(text, PROMPT_MAX_CHARS, "The prompt text");
|
|
38356
|
+
const title = prompt.title?.trim();
|
|
38357
|
+
return [{
|
|
38358
|
+
path,
|
|
38359
|
+
content: `---\n${[...title ? [`title: ${yamlValue(title)}`] : [], `description: ${yamlValue(description)}`].join("\n")}\n---\n\n${text}\n`
|
|
38360
|
+
}];
|
|
38361
|
+
};
|
|
38362
|
+
const checkSkillEntry = (name, text) => {
|
|
38363
|
+
const { fields, body } = parseFrontmatter(text);
|
|
38364
|
+
if (fields.get("name") !== name) fail(`SKILL.md must start with a frontmatter block whose name is "${name}" (---, name: ${name}, description: ..., ---).`);
|
|
38365
|
+
const description = fields.get("description") ?? "";
|
|
38366
|
+
if (description === "" || [
|
|
38367
|
+
">",
|
|
38368
|
+
"|",
|
|
38369
|
+
">-",
|
|
38370
|
+
"|-"
|
|
38371
|
+
].includes(description)) fail("SKILL.md needs a one-line description in its frontmatter: it decides when a client loads the skill.");
|
|
38372
|
+
if (body.trim() === "") fail("SKILL.md has no instructions after its frontmatter.");
|
|
38373
|
+
};
|
|
38374
|
+
const planSkill = (current, name, path, text) => {
|
|
38375
|
+
checkName(name, "skill");
|
|
38376
|
+
const skill = current.skills.get(name);
|
|
38377
|
+
const file = path ?? "SKILL.md";
|
|
38378
|
+
checkSkillPath(file);
|
|
38379
|
+
if (text === null) {
|
|
38380
|
+
if (!skill) fail(`No skill is named "${name}".`);
|
|
38381
|
+
if (file === "SKILL.md") return [...skill?.files.keys() ?? []].map((f) => ({
|
|
38382
|
+
path: `skills/${name}/${f}`,
|
|
38383
|
+
content: null
|
|
38384
|
+
}));
|
|
38385
|
+
if (!skill?.files.has(file)) fail(`The skill "${name}" has no file "${file}".`);
|
|
38386
|
+
return [{
|
|
38387
|
+
path: `skills/${name}/${file}`,
|
|
38388
|
+
content: null
|
|
38389
|
+
}];
|
|
38390
|
+
}
|
|
38391
|
+
if (current.prompts.has(name)) fail(`A prompt is already named "${name}", and every skill is also served as a prompt of its name.`);
|
|
38392
|
+
if (file === "SKILL.md") checkSkillEntry(name, text);
|
|
38393
|
+
else if (!skill) fail(`Create the skill's ${SKILL_ENTRY_FILE} first, then add "${file}".`);
|
|
38394
|
+
checkLength(text, SKILL_FILE_MAX_CHARS, `The file "${file}"`);
|
|
38395
|
+
const files = new Map(skill?.files ?? []);
|
|
38396
|
+
files.set(file, text);
|
|
38397
|
+
if (files.size > 32) fail(`A skill holds at most 32 files.`);
|
|
38398
|
+
const total = [...files.values()].reduce((sum, f) => sum + f.length, 0);
|
|
38399
|
+
if (total > 256e3) fail(`The skill would hold ${total} characters; the limit is ${SKILL_TOTAL_MAX_CHARS}.`);
|
|
38400
|
+
return [{
|
|
38401
|
+
path: `skills/${name}/${file}`,
|
|
38402
|
+
content: text
|
|
38403
|
+
}];
|
|
38404
|
+
};
|
|
38405
|
+
/** The file changes an edit makes, or a `CustomizationError` naming the rule it breaks. */
|
|
38406
|
+
const planEdit = (current, edit) => {
|
|
38407
|
+
if (edit.kind === "instructions") return planInstructions(current, edit.scope, edit.text);
|
|
38408
|
+
if (edit.kind === "prompt") return planPrompt(current, edit.name, edit.prompt);
|
|
38409
|
+
return planSkill(current, edit.name, edit.path, edit.text);
|
|
38410
|
+
};
|
|
38411
|
+
//#endregion
|
|
38412
|
+
//#region src/customization/serve.ts
|
|
38413
|
+
/** How much of a skill a derived prompt carries; the rest stays readable as the skill. */
|
|
38414
|
+
const DERIVED_PROMPT_MAX_CHARS = 16e3;
|
|
38415
|
+
const SCOPE_HEADINGS = {
|
|
38416
|
+
tickets: "For tickets",
|
|
38417
|
+
help_center: "For the Help Center",
|
|
38418
|
+
users: "For users and organizations",
|
|
38419
|
+
requests: "For customers' requests",
|
|
38420
|
+
preferences: "For connector preferences"
|
|
38421
|
+
};
|
|
38422
|
+
/**
|
|
38423
|
+
* The admins' instructions a session with `config`'s namespaces receives: the
|
|
38424
|
+
* global ones, then each active namespace's, or `undefined` when none apply.
|
|
38425
|
+
*/
|
|
38426
|
+
const buildCustomInstructions = (state, config) => {
|
|
38427
|
+
const blocks = [];
|
|
38428
|
+
const global = state.instructions.get("global");
|
|
38429
|
+
if (global) blocks.push(global.text);
|
|
38430
|
+
for (const namespace of config.namespaces) {
|
|
38431
|
+
const entry = state.instructions.get(namespace);
|
|
38432
|
+
if (entry) blocks.push(`${SCOPE_HEADINGS[namespace]}:\n${entry.text}`);
|
|
38433
|
+
}
|
|
38434
|
+
if (blocks.length === 0) return void 0;
|
|
38435
|
+
return ["Your team's own instructions, set by its Zendesk admins:", ...blocks].join("\n\n");
|
|
38436
|
+
};
|
|
38437
|
+
/** Where a client supporting the Skills extension reads the skill. */
|
|
38438
|
+
const skillUri = (name, file = SKILL_ENTRY_FILE) => `skill://${name}/${file}`;
|
|
38439
|
+
/**
|
|
38440
|
+
* A skill as a prompt, for clients without the Skills extension. The header is
|
|
38441
|
+
* the server's, added on read: it sends a client that has the skill back to it,
|
|
38442
|
+
* and tells the others what the conversion dropped.
|
|
38443
|
+
*/
|
|
38444
|
+
const derivedPromptText = (skill) => {
|
|
38445
|
+
const entry = skill.files.get("SKILL.md") ?? "";
|
|
38446
|
+
const body = parseFrontmatter(entry).body.trim();
|
|
38447
|
+
const truncated = body.length > DERIVED_PROMPT_MAX_CHARS;
|
|
38448
|
+
const others = [...skill.files.keys()].filter((file) => file !== SKILL_ENTRY_FILE).sort(compareCodeUnits);
|
|
38449
|
+
return [
|
|
38450
|
+
`[Note from the MCP server] This prompt is the skill "${skill.name}" (${skillUri(skill.name)}) converted for clients that do not support MCP skills.`,
|
|
38451
|
+
`If "${skill.name}" is among the skills available to you, stop reading this prompt: load that skill and follow it instead.`,
|
|
38452
|
+
"Otherwise follow the instructions below, knowing the conversion is lossy: the skill's other files are not included and its scripts cannot run here. If something below does not work as it says, that is a limit of this conversion, not a mistake on your side.",
|
|
38453
|
+
"",
|
|
38454
|
+
"---",
|
|
38455
|
+
"",
|
|
38456
|
+
truncated ? `${body.slice(0, DERIVED_PROMPT_MAX_CHARS)}\n\n[Truncated by the MCP server: the full text is ${skillUri(skill.name)}.]` : body,
|
|
38457
|
+
...others.length > 0 ? [
|
|
38458
|
+
"",
|
|
38459
|
+
"---",
|
|
38460
|
+
"",
|
|
38461
|
+
"Files of this skill not included here (MCP resources, if your client can read them):",
|
|
38462
|
+
...others.map((file) => `- ${skillUri(skill.name, file)}`)
|
|
38463
|
+
] : []
|
|
38464
|
+
].join("\n");
|
|
38465
|
+
};
|
|
38466
|
+
const userMessage = (text) => ({ messages: [{
|
|
38467
|
+
role: "user",
|
|
38468
|
+
content: {
|
|
38469
|
+
type: "text",
|
|
38470
|
+
text
|
|
38471
|
+
}
|
|
38472
|
+
}] });
|
|
38473
|
+
/** The prompts of `state`: the admins' own, then one per skill. Returns the removers. */
|
|
38474
|
+
const registerPrompts = (server, state) => {
|
|
38475
|
+
return [...[...state.prompts.values()].map((prompt) => server.registerPrompt(prompt.name, {
|
|
38476
|
+
...prompt.title ? { title: prompt.title } : {},
|
|
38477
|
+
description: prompt.description
|
|
38478
|
+
}, () => userMessage(prompt.text))), ...[...state.skills.values()].map((skill) => server.registerPrompt(skill.name, { description: `${skill.description} (The skill "${skill.name}" as a prompt: use the skill itself when your client supports MCP skills.)` }, () => userMessage(derivedPromptText(skill))))].map((handle) => () => handle.remove());
|
|
38479
|
+
};
|
|
38480
|
+
/**
|
|
38481
|
+
* Serves the customizations on one `/mcp` server: its prompts and the skills.
|
|
38482
|
+
* `live` for a server that outlives an edit (a 2025 session): its prompts are
|
|
38483
|
+
* swapped in place, which notifies the client; per-request servers are rebuilt.
|
|
38484
|
+
*/
|
|
38485
|
+
const serveCustomizations = (server, service, { live }) => {
|
|
38486
|
+
if (service.current().skills.size > 0) service.skills.attach(server);
|
|
38487
|
+
server.registerPrompt("customizations-placeholder", {}, () => userMessage("")).remove();
|
|
38488
|
+
let removers = registerPrompts(server, service.current());
|
|
38489
|
+
if (!live) return;
|
|
38490
|
+
const unsubscribe = service.onChange(() => {
|
|
38491
|
+
for (const remove of removers) remove();
|
|
38492
|
+
removers = registerPrompts(server, service.current());
|
|
38493
|
+
});
|
|
38494
|
+
const previous = server.server.onclose;
|
|
38495
|
+
server.server.onclose = () => {
|
|
38496
|
+
unsubscribe();
|
|
38497
|
+
previous?.();
|
|
38498
|
+
};
|
|
38499
|
+
};
|
|
38500
|
+
//#endregion
|
|
38501
|
+
//#region src/customization/setup.ts
|
|
38502
|
+
/**
|
|
38503
|
+
* The `setup` guide served on `/mcp/admin`, as a prompt and as a skill. Its
|
|
38504
|
+
* main use is the first configuration; the same steps serve a later revision.
|
|
38505
|
+
*/
|
|
38506
|
+
const SETUP_NAME = "setup";
|
|
38507
|
+
const SETUP_DESCRIPTION = "Set up or revise what this Zendesk MCP server teaches every user's assistant: team instructions, prompts and skills. Use when a Zendesk admin wants to configure, review or change them.";
|
|
38508
|
+
const SETUP_BODY = `# Set up the assistant for your team
|
|
38509
|
+
|
|
38510
|
+
You are helping a Zendesk administrator configure what this MCP server distributes to every user's AI assistant, with the manage_customizations tool.
|
|
38511
|
+
|
|
38512
|
+
## 1. Start from what already exists
|
|
38513
|
+
|
|
38514
|
+
Before proposing anything:
|
|
38515
|
+
|
|
38516
|
+
1. Call manage_customizations with action "list". It shows the built-in context the server already sends on its own, and the customizations already configured.
|
|
38517
|
+
2. Read the built-in context: action "get", kind "built_in", name "instructions" (what every session already receives), then name "topology" (the Help Center structure: locales, categories and sections with ids, segments, permission groups).
|
|
38518
|
+
3. Summarize both for the admin in a few lines, then ask what they want their team's assistants to do differently.
|
|
38519
|
+
|
|
38520
|
+
## 2. Pick the right kind for each need
|
|
38521
|
+
|
|
38522
|
+
- Instructions: short, always-on rules every session receives (tone, vocabulary, what never to do). Every session pays for them in context, so keep them to a few lines per scope. Scope "global" reaches every session; a namespace scope (tickets, help_center, users, requests, preferences) reaches only the sessions that expose it.
|
|
38523
|
+
- Prompt: a workflow a user starts on purpose from their client's prompt menu (draft a reply in our style, triage a ticket). It costs nothing until used.
|
|
38524
|
+
- Skill: reference material or a procedure the assistant loads by itself when a task matches its description (style guide, lexicon, translation workflow). Write that description so it says when to use the skill.
|
|
38525
|
+
|
|
38526
|
+
Ask the admin about their team's practices. Never invent rules on their behalf.
|
|
38527
|
+
|
|
38528
|
+
## 3. Never duplicate what is built in
|
|
38529
|
+
|
|
38530
|
+
Before each set, compare the text with the built-in context you read in step 1. The server already tells every assistant which Help Center it is connected to, its locales, its category and section tree with ids, its segments and permission groups, the user's role, and the SLA alert thresholds. If the admin asks to add any of these (the Help Center URL, the languages, the list of sections...), tell them it is already provided, say where it comes from, and do not write it.
|
|
38531
|
+
|
|
38532
|
+
Also compare new content with the existing customizations, so that two entries never repeat or contradict each other.
|
|
38533
|
+
|
|
38534
|
+
## 4. Mind the clients that do not support skills
|
|
38535
|
+
|
|
38536
|
+
Many MCP clients do not support MCP skills yet. For them the server also serves every skill as a prompt of the same name, but the conversion is lossy: the prompt carries SKILL.md only, truncated when long; the skill's other files are listed but not included, and scripts cannot run. So:
|
|
38537
|
+
|
|
38538
|
+
- put everything essential in SKILL.md itself;
|
|
38539
|
+
- avoid skills that depend on running code;
|
|
38540
|
+
- warn the admin when a skill they want relies on extra files or code.
|
|
38541
|
+
|
|
38542
|
+
## 5. Write, then check
|
|
38543
|
+
|
|
38544
|
+
- Show the admin the exact text before each set, and write it only once they agree.
|
|
38545
|
+
- After setting instructions, the tool returns everything a new session receives. Re-read it for repetitions and contradictions with the built-in part.
|
|
38546
|
+
- Instructions reach new sessions only. Prompts and skills reach open sessions on clients that refresh their lists.
|
|
38547
|
+
- Names use lowercase letters, digits and hyphens. A skill's SKILL.md starts with a frontmatter block holding its name and a one-line description.
|
|
38548
|
+
`;
|
|
38549
|
+
const SETUP_SKILL = `---
|
|
38550
|
+
name: ${SETUP_NAME}
|
|
38551
|
+
description: ${JSON.stringify(SETUP_DESCRIPTION)}
|
|
38552
|
+
---
|
|
38553
|
+
|
|
38554
|
+
${SETUP_BODY}`;
|
|
38555
|
+
//#endregion
|
|
38556
|
+
//#region src/customization/admin.ts
|
|
38557
|
+
const ADMIN_ONLY = "This back-office is reserved for Zendesk administrators: your Zendesk role is not admin.";
|
|
38558
|
+
/**
|
|
38559
|
+
* The caller, re-read from Zendesk on every call: a token minted for an admin
|
|
38560
|
+
* stays valid up to an hour after a demotion, this check does not.
|
|
38561
|
+
*/
|
|
38562
|
+
const requireAdmin = async (subdomain, getToken) => {
|
|
38563
|
+
const { user } = await zendeskGet(subdomain, await getToken(), "/users/me");
|
|
38564
|
+
if (user.role !== "admin") throw new Error(ADMIN_ONLY);
|
|
38565
|
+
return {
|
|
38566
|
+
id: user.id,
|
|
38567
|
+
name: user.name ?? `user ${user.id}`
|
|
38568
|
+
};
|
|
38569
|
+
};
|
|
38570
|
+
const text = (value) => ({ content: [{
|
|
38571
|
+
type: "text",
|
|
38572
|
+
text: value
|
|
38573
|
+
}] });
|
|
38574
|
+
const describeAuthor = (author) => author ? `, last changed by ${author.name} on ${author.at.slice(0, 10)}` : "";
|
|
38575
|
+
/** What a new `/mcp` session receives as `instructions`, built-in part first. */
|
|
38576
|
+
const effectiveInstructions = (config, state) => [buildInstructions(config), buildCustomInstructions(state, config)].filter((block) => block !== void 0).join("\n\n") || "(none)";
|
|
38577
|
+
const listCustomizations = (env) => {
|
|
38578
|
+
const state = env.service.current();
|
|
38579
|
+
return [
|
|
38580
|
+
"## Built in (read only, sent by the server on its own)",
|
|
38581
|
+
"- instructions: what every session already receives. Read it with action \"get\", kind \"built_in\", name \"instructions\".",
|
|
38582
|
+
"- topology: the Help Center structure (locales, categories and sections with ids, segments, permission groups, the caller's role), served to every session as a resource. Read it with kind \"built_in\", name \"topology\".",
|
|
38583
|
+
"",
|
|
38584
|
+
"## Instructions",
|
|
38585
|
+
...state.instructions.size === 0 ? ["(none)"] : [...state.instructions.values()].map((entry) => `- ${entry.scope}: ${entry.text.length} characters${describeAuthor(entry.author)}`),
|
|
38586
|
+
"",
|
|
38587
|
+
"## Prompts",
|
|
38588
|
+
...state.prompts.size === 0 ? ["(none)"] : [...state.prompts.values()].map((prompt) => `- ${prompt.name}: ${prompt.description}${describeAuthor(prompt.author)}`),
|
|
38589
|
+
"",
|
|
38590
|
+
"## Skills",
|
|
38591
|
+
...state.skills.size === 0 ? ["(none)"] : [...state.skills.values()].map((skill) => `- ${skill.name}: ${skill.description} (files: ${sorted(skill.files)})${describeAuthor(skill.author)}`),
|
|
38592
|
+
"",
|
|
38593
|
+
`Limits: ${INSTRUCTION_MAX_CHARS} characters per instructions scope and ${INSTRUCTIONS_TOTAL_MAX_CHARS} for all of them; ${PROMPT_MAX_CHARS} per prompt; a skill's derived prompt carries the first ${DERIVED_PROMPT_MAX_CHARS} characters of its SKILL.md.`
|
|
38594
|
+
].join("\n");
|
|
38595
|
+
};
|
|
38596
|
+
const ACTIONS = [
|
|
38597
|
+
"list",
|
|
38598
|
+
"get",
|
|
38599
|
+
"set",
|
|
38600
|
+
"delete"
|
|
38601
|
+
];
|
|
38602
|
+
const KINDS = [
|
|
38603
|
+
"instructions",
|
|
38604
|
+
"prompt",
|
|
38605
|
+
"skill",
|
|
38606
|
+
"built_in"
|
|
38607
|
+
];
|
|
38608
|
+
const BUILT_IN = ["instructions", "topology"];
|
|
38609
|
+
const inputSchema = object({
|
|
38610
|
+
action: _enum(ACTIONS).describe("list: everything configured plus the built-in context; get: read one entry; set: create or replace one entry; delete: remove one entry."),
|
|
38611
|
+
kind: _enum(KINDS).optional().describe("What the entry is: instructions (always-on text every matching session receives), prompt (a workflow users start from their prompt menu), skill (a SKILL.md, plus optional files, the assistant loads when a task matches), or built_in (what the server already sends, read only). Required except for list."),
|
|
38612
|
+
name: string().optional().describe(`Which entry. For instructions, the scope: ${INSTRUCTION_SCOPES.join(", ")} (global reaches every session, a namespace only sessions exposing it). For prompts and skills, a lowercase-hyphenated name such as draft-reply, shared by both kinds. For built_in: instructions or topology. Required except for list.`),
|
|
38613
|
+
path: string().optional().describe(`Skills only: the file inside the skill, such as references/lexicon.md. Defaults to ${SKILL_ENTRY_FILE}; deleting ${SKILL_ENTRY_FILE} (or giving no path) deletes the whole skill.`),
|
|
38614
|
+
content: string().optional().describe(`For set: the full new text, replacing the old one. Instructions and prompts are plain Markdown; a skill's ${SKILL_ENTRY_FILE} starts with a YAML frontmatter block holding name (the skill name) and a one-line description (quoted if it contains ": ").`),
|
|
38615
|
+
description: string().optional().describe("For set on a prompt: the one-line description clients show in their prompt menu. Required there."),
|
|
38616
|
+
title: string().optional().describe("For set on a prompt: an optional human-readable title shown instead of the name.")
|
|
38617
|
+
});
|
|
38618
|
+
const required = (value, what) => {
|
|
38619
|
+
if (value === void 0 || value === "") throw customizationError(`${what} is required here.`);
|
|
38620
|
+
return value;
|
|
38621
|
+
};
|
|
38622
|
+
const asScope = (name) => {
|
|
38623
|
+
if (!INSTRUCTION_SCOPES.includes(name)) throw customizationError(`Unknown instructions scope "${name}": use one of ${INSTRUCTION_SCOPES.join(", ")}.`);
|
|
38624
|
+
return name;
|
|
38625
|
+
};
|
|
38626
|
+
const sorted = (files) => [...files.keys()].sort(compareCodeUnits).join(", ");
|
|
38627
|
+
const READERS = {
|
|
38628
|
+
built_in: (env, name, _params, topology) => {
|
|
38629
|
+
if (name === "instructions") return buildInstructions(env.config) ?? "(none)";
|
|
38630
|
+
if (name === "topology") return topology().read();
|
|
38631
|
+
throw customizationError(`Unknown built-in "${name}": use ${BUILT_IN.join(" or ")}.`);
|
|
38632
|
+
},
|
|
38633
|
+
instructions: (env, name) => env.service.current().instructions.get(asScope(name))?.text ?? `No instructions are set for "${name}".`,
|
|
38634
|
+
prompt: (env, name) => {
|
|
38635
|
+
const prompt = env.service.current().prompts.get(name);
|
|
38636
|
+
if (!prompt) return `No prompt is named "${name}".`;
|
|
38637
|
+
return [
|
|
38638
|
+
...prompt.title ? [`Title: ${prompt.title}`] : [],
|
|
38639
|
+
`Description: ${prompt.description}`,
|
|
38640
|
+
"",
|
|
38641
|
+
prompt.text
|
|
38642
|
+
].join("\n");
|
|
38643
|
+
},
|
|
38644
|
+
skill: (env, name, params) => {
|
|
38645
|
+
const skill = env.service.current().skills.get(name);
|
|
38646
|
+
if (!skill) return `No skill is named "${name}".`;
|
|
38647
|
+
const path = params.path ?? "SKILL.md";
|
|
38648
|
+
const file = skill.files.get(path);
|
|
38649
|
+
if (file === void 0) return `The skill "${name}" has no file "${path}".`;
|
|
38650
|
+
return `Files: ${sorted(skill.files)}\nURI: ${skillUri(name, path)}\n\n${file}`;
|
|
38651
|
+
}
|
|
38652
|
+
};
|
|
38653
|
+
const getEntry = (env, params, topology) => READERS[required(params.kind, "kind")](env, required(params.name, "name"), params, topology);
|
|
38654
|
+
const EDITS = {
|
|
38655
|
+
instructions: (name, content) => ({
|
|
38656
|
+
kind: "instructions",
|
|
38657
|
+
scope: asScope(name),
|
|
38658
|
+
text: content
|
|
38659
|
+
}),
|
|
38660
|
+
prompt: (name, content, params) => ({
|
|
38661
|
+
kind: "prompt",
|
|
38662
|
+
name,
|
|
38663
|
+
prompt: content === null ? null : {
|
|
38664
|
+
description: required(params.description, "description"),
|
|
38665
|
+
text: content,
|
|
38666
|
+
...params.title === void 0 ? {} : { title: params.title }
|
|
38667
|
+
}
|
|
38668
|
+
}),
|
|
38669
|
+
skill: (name, content, params) => ({
|
|
38670
|
+
kind: "skill",
|
|
38671
|
+
name,
|
|
38672
|
+
...params.path === void 0 ? {} : { path: params.path },
|
|
38673
|
+
text: content
|
|
38674
|
+
}),
|
|
38675
|
+
built_in: () => {
|
|
38676
|
+
throw customizationError("The built-in context is read only: it comes from the server and Zendesk.");
|
|
38677
|
+
}
|
|
38678
|
+
};
|
|
38679
|
+
const toEdit = (params, deleting) => EDITS[required(params.kind, "kind")](required(params.name, "name"), deleting ? null : required(params.content, "content"), params);
|
|
38680
|
+
const confirm = (env, params, deleting, state) => {
|
|
38681
|
+
const done = deleting ? "Deleted" : "Saved";
|
|
38682
|
+
const what = `${params.kind} "${params.name}"${params.path ? ` (${params.path})` : ""}`;
|
|
38683
|
+
if (params.kind === "instructions") return `${done} ${what}. A new session on /mcp now receives these instructions (the built-in part first); check them for repetitions and contradictions:\n\n${effectiveInstructions(env.config, state)}`;
|
|
38684
|
+
const name = params.name ?? "";
|
|
38685
|
+
if (params.kind === "skill" && state.skills.has(name)) return `${done} ${what}. Clients supporting MCP skills get it as ${skillUri(name)}; the others get the prompt "${name}", carrying ${SKILL_ENTRY_FILE} only.`;
|
|
38686
|
+
if (deleting) return `${done} ${what}. It leaves the prompt menu of clients that refresh their prompt list.`;
|
|
38687
|
+
return `${done} ${what}. It appears in the prompt menu of clients that refresh their prompt list.`;
|
|
38688
|
+
};
|
|
38689
|
+
const createAdminTools = (env) => {
|
|
38690
|
+
const topologyFor = (caller) => () => createTopologyProvider(caller.getToken, env.config.subdomain, void 0, env.config.brandIds);
|
|
38691
|
+
return [{
|
|
38692
|
+
name: "manage_customizations",
|
|
38693
|
+
readOnly: false,
|
|
38694
|
+
title: "Manage Team Customizations",
|
|
38695
|
+
description: "Read and change what this MCP server teaches every user's assistant on /mcp: instructions (always-on text), prompts (workflows users start from their prompt menu) and skills (SKILL.md files the assistant loads when a task matches). list shows them all with who changed each, plus the built-in context the server already sends; get reads one entry, or the built-in instructions and Help Center topology; set creates or replaces an entry; delete removes one. Writes go to this server's store, never to Zendesk. Reserved for Zendesk admins, checked on every call. Before setting anything, read the built-in context and do not repeat it; the setup prompt walks through the whole configuration.",
|
|
38696
|
+
inputSchema,
|
|
38697
|
+
annotations: {
|
|
38698
|
+
readOnlyHint: false,
|
|
38699
|
+
destructiveHint: true,
|
|
38700
|
+
idempotentHint: true,
|
|
38701
|
+
openWorldHint: false
|
|
38702
|
+
},
|
|
38703
|
+
handler: async (raw, caller) => {
|
|
38704
|
+
const params = raw;
|
|
38705
|
+
const admin = await requireAdmin(env.config.subdomain, caller.getToken);
|
|
38706
|
+
try {
|
|
38707
|
+
if (params.action === "list") return text(listCustomizations(env));
|
|
38708
|
+
if (params.action === "get") return text(await getEntry(env, params, topologyFor(caller)));
|
|
38709
|
+
const deleting = params.action === "delete";
|
|
38710
|
+
const state = await env.service.edit(toEdit(params, deleting), {
|
|
38711
|
+
name: `${admin.name} (Zendesk user ${admin.id})`,
|
|
38712
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
38713
|
+
});
|
|
38714
|
+
return text(confirm(env, params, deleting, state));
|
|
38715
|
+
} catch (err) {
|
|
38716
|
+
if (isCustomizationError(err)) throw new Error(`${err.message} Nothing was changed.`, { cause: err });
|
|
38717
|
+
throw err;
|
|
38718
|
+
}
|
|
38719
|
+
}
|
|
38720
|
+
}];
|
|
38721
|
+
};
|
|
38722
|
+
const ADMIN_INSTRUCTIONS = `This is the back-office of the Zendesk MCP server, reserved for Zendesk admins. It configures what the server teaches every user's assistant on its main endpoint: instructions, prompts and skills. Start with the "${SETUP_NAME}" prompt, or the skill of the same name: it reads what already exists and guides each change.`;
|
|
38723
|
+
/** The `setup` guide as a skill, the same for every admin: built once per process. */
|
|
38724
|
+
let setupSkills;
|
|
38725
|
+
const loadSetupSkill = () => {
|
|
38726
|
+
setupSkills ??= SkillCatalog.load(memorySkillFiles({ [`${SETUP_NAME}/${SKILL_ENTRY_FILE}`]: SETUP_SKILL }));
|
|
38727
|
+
return setupSkills;
|
|
38728
|
+
};
|
|
38729
|
+
/** One `/mcp/admin` server: the admin tools, the `setup` prompt and skill, nothing of Zendesk's. */
|
|
38730
|
+
const createAdminServer = ({ readOnly, tools, setupSkill, caller, requestStateCodec, onUnauthorized }) => {
|
|
38731
|
+
const pkg = readPackageInfo();
|
|
38732
|
+
const server = new McpServer({
|
|
38733
|
+
name: pkg.name,
|
|
38734
|
+
version: pkg.version
|
|
38735
|
+
}, {
|
|
38736
|
+
instructions: ADMIN_INSTRUCTIONS,
|
|
38737
|
+
requestState: { verify: (state, ctx) => requestStateCodec.verify(state, ctx) }
|
|
38738
|
+
});
|
|
38739
|
+
for (const tool of tools.filter((t) => t.readOnly || !readOnly)) server.registerTool(tool.name, {
|
|
38740
|
+
title: tool.title,
|
|
38741
|
+
description: tool.description,
|
|
38742
|
+
inputSchema: toStrictStandardSchema(tool.inputSchema),
|
|
38743
|
+
annotations: tool.annotations
|
|
38744
|
+
}, async (params) => {
|
|
38745
|
+
try {
|
|
38746
|
+
return await tool.handler(params, caller);
|
|
38747
|
+
} catch (err) {
|
|
38748
|
+
if (err instanceof ZendeskApiError && err.status === 401) onUnauthorized?.();
|
|
38749
|
+
throw err;
|
|
38750
|
+
}
|
|
38751
|
+
});
|
|
38752
|
+
server.registerPrompt(SETUP_NAME, {
|
|
38753
|
+
title: "Set up the team assistant",
|
|
38754
|
+
description: SETUP_DESCRIPTION
|
|
38755
|
+
}, () => ({ messages: [{
|
|
38756
|
+
role: "user",
|
|
38757
|
+
content: {
|
|
38758
|
+
type: "text",
|
|
38759
|
+
text: SETUP_BODY
|
|
38760
|
+
}
|
|
38761
|
+
}] }));
|
|
38762
|
+
setupSkill.attach(server);
|
|
38763
|
+
return server;
|
|
38764
|
+
};
|
|
38765
|
+
//#endregion
|
|
38766
|
+
//#region src/customization/backend.ts
|
|
38767
|
+
const AUTHORS_FILE = ".authors.json";
|
|
38768
|
+
const TEMP_FILE = /\.[0-9a-f-]{36}\.tmp$/;
|
|
38769
|
+
/** Nothing on disk: tests, and `memory://` deployments that accept losing it on restart. */
|
|
38770
|
+
const createMemoryBackend = (seed = {}) => {
|
|
38771
|
+
const files = new Map(Object.entries(seed));
|
|
38772
|
+
const authors = /* @__PURE__ */ new Map();
|
|
38773
|
+
return {
|
|
38774
|
+
load: async () => ({
|
|
38775
|
+
files: new Map(files),
|
|
38776
|
+
authors: new Map(authors)
|
|
38777
|
+
}),
|
|
38778
|
+
apply: async (changes, author) => {
|
|
38779
|
+
for (const { path, content } of changes) if (content === null) {
|
|
38780
|
+
files.delete(path);
|
|
38781
|
+
authors.delete(path);
|
|
38782
|
+
} else {
|
|
38783
|
+
files.set(path, content);
|
|
38784
|
+
authors.set(path, author);
|
|
38785
|
+
}
|
|
38786
|
+
}
|
|
38787
|
+
};
|
|
38788
|
+
};
|
|
38789
|
+
const listFiles = async (root, dir = "") => {
|
|
38790
|
+
const entries = await readdir(join(root, dir), { withFileTypes: true }).catch((err) => {
|
|
38791
|
+
if (err.code === "ENOENT") return [];
|
|
38792
|
+
throw err;
|
|
38793
|
+
});
|
|
38794
|
+
return (await Promise.all(entries.map(async (entry) => {
|
|
38795
|
+
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
38796
|
+
if (entry.isDirectory()) return listFiles(root, path);
|
|
38797
|
+
return entry.isFile() && !TEMP_FILE.test(entry.name) && path !== AUTHORS_FILE ? [path] : [];
|
|
38798
|
+
}))).flat();
|
|
38799
|
+
};
|
|
38800
|
+
const writeAtomic = async (path, text) => {
|
|
38801
|
+
await mkdir(dirname(path), {
|
|
38802
|
+
recursive: true,
|
|
38803
|
+
mode: 448
|
|
38804
|
+
});
|
|
38805
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
38806
|
+
await writeFile(temp, text, { mode: 384 });
|
|
38807
|
+
await rename(temp, path);
|
|
38808
|
+
};
|
|
38809
|
+
/**
|
|
38810
|
+
* One Markdown file per entry under `root`, readable and versionable as is; the
|
|
38811
|
+
* authors live beside them in `.authors.json`. Each file is replaced by a
|
|
38812
|
+
* rename, so a reader never sees half of one.
|
|
38813
|
+
*/
|
|
38814
|
+
const createFileBackend = (dir) => {
|
|
38815
|
+
const root = resolve(dir);
|
|
38816
|
+
const authorsPath = join(root, AUTHORS_FILE);
|
|
38817
|
+
const readAuthors = async () => {
|
|
38818
|
+
try {
|
|
38819
|
+
return JSON.parse(await readFile(authorsPath, "utf8"));
|
|
38820
|
+
} catch {
|
|
38821
|
+
return {};
|
|
38822
|
+
}
|
|
38823
|
+
};
|
|
38824
|
+
const resolvePath = (path) => join(root, ...path.split("/"));
|
|
38825
|
+
return {
|
|
38826
|
+
load: async () => {
|
|
38827
|
+
const paths = await listFiles(root);
|
|
38828
|
+
return {
|
|
38829
|
+
files: new Map(await Promise.all(paths.map(async (path) => [path, await readFile(resolvePath(path), "utf8")]))),
|
|
38830
|
+
authors: new Map(Object.entries(await readAuthors()))
|
|
38831
|
+
};
|
|
38832
|
+
},
|
|
38833
|
+
apply: async (changes, author) => {
|
|
38834
|
+
const authors = await readAuthors();
|
|
38835
|
+
for (const { path, content } of changes) {
|
|
38836
|
+
const target = resolvePath(path);
|
|
38837
|
+
if (!target.startsWith(`${root}${sep}`)) throw new Error(`Invalid path: ${path}`);
|
|
38838
|
+
if (content === null) {
|
|
38839
|
+
await rm(target, { force: true });
|
|
38840
|
+
delete authors[path];
|
|
38841
|
+
} else {
|
|
38842
|
+
await writeAtomic(target, content);
|
|
38843
|
+
authors[path] = author;
|
|
38844
|
+
}
|
|
38845
|
+
}
|
|
38846
|
+
await writeAtomic(authorsPath, `${JSON.stringify(authors, null, 2)}\n`);
|
|
38847
|
+
}
|
|
38848
|
+
};
|
|
38849
|
+
};
|
|
38850
|
+
/** `file://<dir>` or `memory://`, as `CUSTOMIZATION_STORE` names it. */
|
|
38851
|
+
const openCustomizationBackend = (uri) => {
|
|
38852
|
+
const url = new URL(uri);
|
|
38853
|
+
if (url.protocol === "memory:") return createMemoryBackend();
|
|
38854
|
+
if (url.protocol === "file:") return createFileBackend(fileURLToPath(url));
|
|
38855
|
+
throw new Error(`Unsupported CUSTOMIZATION_STORE "${url.protocol}//": use file://<directory> or memory://.`);
|
|
38856
|
+
};
|
|
38857
|
+
//#endregion
|
|
38858
|
+
//#region src/customization/service.ts
|
|
38859
|
+
const encoder = new TextEncoder();
|
|
38860
|
+
/**
|
|
38861
|
+
* The skills of `state()` as the SDK's storage adapter sees them: one folder
|
|
38862
|
+
* per skill at the root. Read live, so `catalog.reload()` picks up an edit.
|
|
38863
|
+
*/
|
|
38864
|
+
const skillFiles = (state) => {
|
|
38865
|
+
const paths = () => [...state().skills.values()].flatMap((skill) => [...skill.files.keys()].map((file) => `${skill.name}/${file}`));
|
|
38866
|
+
return {
|
|
38867
|
+
list: async (dir) => {
|
|
38868
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
38869
|
+
const children = /* @__PURE__ */ new Map();
|
|
38870
|
+
for (const path of paths()) {
|
|
38871
|
+
if (!path.startsWith(prefix)) continue;
|
|
38872
|
+
const [name = "", ...rest] = path.slice(prefix.length).split("/");
|
|
38873
|
+
children.set(name, rest.length > 0 ? "directory" : "file");
|
|
38874
|
+
}
|
|
38875
|
+
return [...children].map(([name, kind]) => ({
|
|
38876
|
+
name,
|
|
38877
|
+
kind
|
|
38878
|
+
}));
|
|
38879
|
+
},
|
|
38880
|
+
read: async (path) => {
|
|
38881
|
+
const [name = "", ...rest] = path.split("/");
|
|
38882
|
+
const text = state().skills.get(name)?.files.get(rest.join("/"));
|
|
38883
|
+
if (text === void 0) throw new Error(`No skill file at ${path}`);
|
|
38884
|
+
return encoder.encode(text);
|
|
38885
|
+
}
|
|
38886
|
+
};
|
|
38887
|
+
};
|
|
38888
|
+
/**
|
|
38889
|
+
* The skill as it would be after `changes`, checked by the SDK the way the
|
|
38890
|
+
* catalog will read it (YAML frontmatter, name matching the folder): a skill
|
|
38891
|
+
* it would skip is refused now, with its reason, rather than silently dropped.
|
|
38892
|
+
*/
|
|
38893
|
+
const checkSkill = async (state, name, changes) => {
|
|
38894
|
+
const files = new Map(state.skills.get(name)?.files ?? []);
|
|
38895
|
+
for (const { path, content } of changes) {
|
|
38896
|
+
const file = path.slice(`skills/${name}/`.length);
|
|
38897
|
+
if (content === null) files.delete(file);
|
|
38898
|
+
else files.set(file, content);
|
|
38899
|
+
}
|
|
38900
|
+
try {
|
|
38901
|
+
await skillEntry(name, Object.fromEntries(files));
|
|
38902
|
+
} catch (err) {
|
|
38903
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
38904
|
+
throw customizationError(`The skill would not be served: ${reason} (${SKILL_ENTRY_FILE} frontmatter is YAML: quote a value holding ": ").`);
|
|
38905
|
+
}
|
|
38906
|
+
};
|
|
38907
|
+
const createCustomizationService = async (backend, logger = silentLogger) => {
|
|
38908
|
+
const load = async () => {
|
|
38909
|
+
const state = parseCustomizations(await backend.load());
|
|
38910
|
+
if (state.ignored.length > 0) logger.warn("customizations_ignored", {
|
|
38911
|
+
files: state.ignored,
|
|
38912
|
+
hint: "Files the customization store holds but serves nothing for: a malformed entry, or a file placed by hand."
|
|
38913
|
+
});
|
|
38914
|
+
return state;
|
|
38915
|
+
};
|
|
38916
|
+
let state = await load();
|
|
38917
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
38918
|
+
const skills = await SkillCatalog.load(skillFiles(() => state), { onWarning: (warning) => {
|
|
38919
|
+
if (state.skills.size > 0) logger.warn("customization_skill_skipped", { ...warning });
|
|
38920
|
+
} });
|
|
38921
|
+
let queue = Promise.resolve();
|
|
38922
|
+
const apply = async (edit, author) => {
|
|
38923
|
+
const changes = planEdit(state, edit);
|
|
38924
|
+
if (edit.kind === "skill" && edit.text !== null) await checkSkill(state, edit.name, changes);
|
|
38925
|
+
let failure;
|
|
38926
|
+
try {
|
|
38927
|
+
await backend.apply(changes, author);
|
|
38928
|
+
} catch (error) {
|
|
38929
|
+
failure = { error };
|
|
38930
|
+
}
|
|
38931
|
+
state = await load();
|
|
38932
|
+
await skills.reload();
|
|
38933
|
+
for (const listener of listeners) listener();
|
|
38934
|
+
if (failure) throw failure.error;
|
|
38935
|
+
return state;
|
|
38936
|
+
};
|
|
38937
|
+
return {
|
|
38938
|
+
current: () => state,
|
|
38939
|
+
edit: (edit, author) => {
|
|
38940
|
+
const run = queue.then(() => apply(edit, author));
|
|
38941
|
+
queue = run.catch(() => void 0);
|
|
38942
|
+
return run;
|
|
38943
|
+
},
|
|
38944
|
+
onChange: (listener) => {
|
|
38945
|
+
listeners.add(listener);
|
|
38946
|
+
return () => listeners.delete(listener);
|
|
38947
|
+
},
|
|
38948
|
+
skills
|
|
38949
|
+
};
|
|
38950
|
+
};
|
|
38951
|
+
//#endregion
|
|
36571
38952
|
//#region src/utils/grant-registry.ts
|
|
36572
38953
|
const createGrantRegistry = ({ idleTimeoutMs, now = Date.now, onCloseError = () => void 0 }) => {
|
|
36573
38954
|
const slots = /* @__PURE__ */ new Map();
|
|
@@ -36708,7 +39089,7 @@ const SPACES = / +/;
|
|
|
36708
39089
|
* resource (`expectedResource`) and the expiry; decrypting and every other
|
|
36709
39090
|
* claim stay with the authorization server.
|
|
36710
39091
|
*/
|
|
36711
|
-
const authenticate = async (request, as) => {
|
|
39092
|
+
const authenticate = async (request, as, resource = as.resource) => {
|
|
36712
39093
|
const header = request.headers["authorization"];
|
|
36713
39094
|
const verifier = { verifyAccessToken: async (bearer) => {
|
|
36714
39095
|
const token = await as.verifyAccessToken(bearer);
|
|
@@ -36726,13 +39107,14 @@ const authenticate = async (request, as) => {
|
|
|
36726
39107
|
const normalized = typeof header === "string" ? header.replace(SPACES, " ") : void 0;
|
|
36727
39108
|
return (await verifyBearerToken(normalized, {
|
|
36728
39109
|
verifier,
|
|
36729
|
-
expectedResource: new URL(
|
|
39110
|
+
expectedResource: new URL(resource)
|
|
36730
39111
|
})).extra?.["verified"];
|
|
36731
39112
|
} catch {
|
|
36732
39113
|
return;
|
|
36733
39114
|
}
|
|
36734
39115
|
};
|
|
36735
39116
|
const PROTECTED_RESOURCE_PATHS = ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"];
|
|
39117
|
+
const ADMIN_PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource/mcp/admin";
|
|
36736
39118
|
const sendJson = (res, status, body) => {
|
|
36737
39119
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
36738
39120
|
res.end(JSON.stringify(body));
|
|
@@ -36759,8 +39141,8 @@ const failRequest = (res, err) => {
|
|
|
36759
39141
|
}
|
|
36760
39142
|
if (!res.writableEnded) res.end();
|
|
36761
39143
|
};
|
|
36762
|
-
const sendUnauthorized = (res, issuer) => {
|
|
36763
|
-
const wwwAuthenticate = `Bearer resource_metadata="${issuer}${PROTECTED_RESOURCE_PATHS[0]}", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
39144
|
+
const sendUnauthorized = (res, issuer, surface) => {
|
|
39145
|
+
const wwwAuthenticate = `Bearer resource_metadata="${issuer}${surface === "admin" ? ADMIN_PROTECTED_RESOURCE_PATH : PROTECTED_RESOURCE_PATHS[0]}", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
36764
39146
|
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
|
|
36765
39147
|
};
|
|
36766
39148
|
const asNodeRequest = (req) => req;
|
|
@@ -36830,6 +39212,12 @@ const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
|
36830
39212
|
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
36831
39213
|
const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
|
|
36832
39214
|
const buildAuthorizationServer = await prepareAuthorizationServer(config, logger);
|
|
39215
|
+
const customizations = await createCustomizationService(openCustomizationBackend(config.customizationStore ?? pathToFileURL(join(configDir(), "customizations")).href), logger);
|
|
39216
|
+
const setupSkill = await loadSetupSkill();
|
|
39217
|
+
const adminTools = createAdminTools({
|
|
39218
|
+
config,
|
|
39219
|
+
service: customizations
|
|
39220
|
+
});
|
|
36833
39221
|
let authorizationServer;
|
|
36834
39222
|
const sessions = /* @__PURE__ */ new Map();
|
|
36835
39223
|
const sla = startSlaRuntime(config);
|
|
@@ -36848,9 +39236,9 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36848
39236
|
...config,
|
|
36849
39237
|
readOnly: true
|
|
36850
39238
|
};
|
|
36851
|
-
const dispatchToSession = async (req, res, sessionId, token, body) => {
|
|
39239
|
+
const dispatchToSession = async (req, res, sessionId, token, body, surface) => {
|
|
36852
39240
|
const session = sessions.get(sessionId);
|
|
36853
|
-
if (!session || session.subject !== token.subject) return false;
|
|
39241
|
+
if (!session || session.subject !== token.subject || session.surface !== surface) return false;
|
|
36854
39242
|
if (session.canWrite && !token.canWrite) return false;
|
|
36855
39243
|
session.auth.zendeskToken = token.zendeskAccessToken;
|
|
36856
39244
|
session.auth.grantId = token.grantId;
|
|
@@ -36858,13 +39246,29 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36858
39246
|
await session.transport.handleRequest(req, res, body);
|
|
36859
39247
|
return true;
|
|
36860
39248
|
};
|
|
36861
|
-
const
|
|
39249
|
+
const buildServer = (surface, params, live) => surface === "admin" ? createAdminServer({
|
|
39250
|
+
env: {
|
|
39251
|
+
config,
|
|
39252
|
+
service: customizations
|
|
39253
|
+
},
|
|
39254
|
+
readOnly: params.config.readOnly,
|
|
39255
|
+
tools: adminTools,
|
|
39256
|
+
setupSkill,
|
|
39257
|
+
caller: { getToken: params.getToken },
|
|
39258
|
+
requestStateCodec: params.requestStateCodec,
|
|
39259
|
+
onUnauthorized: params.onUnauthorized
|
|
39260
|
+
}) : serverFactory({
|
|
39261
|
+
...params,
|
|
39262
|
+
extraInstructions: buildCustomInstructions(customizations.current(), params.config),
|
|
39263
|
+
decorate: (server) => serveCustomizations(server, customizations, { live })
|
|
39264
|
+
});
|
|
39265
|
+
const openSession = async (req, res, body, token, as, surface) => {
|
|
36862
39266
|
const auth = {
|
|
36863
39267
|
zendeskToken: token.zendeskAccessToken,
|
|
36864
39268
|
grantId: token.grantId
|
|
36865
39269
|
};
|
|
36866
39270
|
const sessionConfig = effectiveConfig(token);
|
|
36867
|
-
const server =
|
|
39271
|
+
const server = buildServer(surface, {
|
|
36868
39272
|
config: sessionConfig,
|
|
36869
39273
|
getToken: () => auth.zendeskToken,
|
|
36870
39274
|
logger,
|
|
@@ -36878,7 +39282,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36878
39282
|
notifyResourceUpdated: (uri) => {
|
|
36879
39283
|
server.server.sendResourceUpdated({ uri }).catch(() => void 0);
|
|
36880
39284
|
}
|
|
36881
|
-
});
|
|
39285
|
+
}, true);
|
|
36882
39286
|
const transport = new NodeStreamableHTTPServerTransport({
|
|
36883
39287
|
sessionIdGenerator: () => randomUUID(),
|
|
36884
39288
|
onsessioninitialized: (newId) => {
|
|
@@ -36886,6 +39290,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36886
39290
|
transport,
|
|
36887
39291
|
auth,
|
|
36888
39292
|
subject: token.subject,
|
|
39293
|
+
surface,
|
|
36889
39294
|
canWrite: !sessionConfig.readOnly,
|
|
36890
39295
|
lastActivityAt: Date.now(),
|
|
36891
39296
|
close: async () => {
|
|
@@ -36900,8 +39305,9 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36900
39305
|
};
|
|
36901
39306
|
await server.connect(transport);
|
|
36902
39307
|
await transport.handleRequest(req, res, body);
|
|
39308
|
+
if (transport.sessionId === void 0) await server.close();
|
|
36903
39309
|
};
|
|
36904
|
-
const createGrantEntry = (token, as) => {
|
|
39310
|
+
const createGrantEntry = (token, as, surface) => {
|
|
36905
39311
|
const { grantId } = token;
|
|
36906
39312
|
const auth = { zendeskToken: token.zendeskAccessToken };
|
|
36907
39313
|
const getToken = () => auth.zendeskToken;
|
|
@@ -36917,7 +39323,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36917
39323
|
const handler = createMcpHandler(({ authInfo }) => {
|
|
36918
39324
|
const verified = authInfo?.extra?.["verified"];
|
|
36919
39325
|
if (!verified) throw new Error("2026-07-28 request without a verified token");
|
|
36920
|
-
return
|
|
39326
|
+
return buildServer(surface, {
|
|
36921
39327
|
config: effectiveConfig(verified),
|
|
36922
39328
|
getToken,
|
|
36923
39329
|
logger,
|
|
@@ -36928,13 +39334,18 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36928
39334
|
slaRuntime: sla.runtime,
|
|
36929
39335
|
alertPolling: "grant",
|
|
36930
39336
|
notifyResourceUpdated: (uri) => handler.notify.resourceUpdated(uri)
|
|
36931
|
-
});
|
|
39337
|
+
}, false);
|
|
36932
39338
|
}, {
|
|
36933
39339
|
legacy: "reject",
|
|
36934
39340
|
onerror: (err) => logger.debug("mcp_request_rejected", { error: err.message })
|
|
36935
39341
|
});
|
|
36936
39342
|
const listens = createListenFeed();
|
|
36937
|
-
const
|
|
39343
|
+
const main = surface === "main";
|
|
39344
|
+
const stopCustomizations = main ? customizations.onChange(() => {
|
|
39345
|
+
handler.notify.promptsChanged();
|
|
39346
|
+
handler.notify.resourcesChanged();
|
|
39347
|
+
}) : () => void 0;
|
|
39348
|
+
const stopAlerts = main ? watchGrantAlerts({
|
|
36938
39349
|
config,
|
|
36939
39350
|
getToken,
|
|
36940
39351
|
caches,
|
|
@@ -36943,7 +39354,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36943
39354
|
listens,
|
|
36944
39355
|
notify: (uri) => handler.notify.resourceUpdated(uri),
|
|
36945
39356
|
onUnauthorized
|
|
36946
|
-
});
|
|
39357
|
+
}) : () => void 0;
|
|
36947
39358
|
const entry = {
|
|
36948
39359
|
auth,
|
|
36949
39360
|
revoked: false,
|
|
@@ -36951,6 +39362,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36951
39362
|
listens,
|
|
36952
39363
|
handle: toNodeHandler(handler),
|
|
36953
39364
|
close: () => {
|
|
39365
|
+
stopCustomizations();
|
|
36954
39366
|
stopAlerts();
|
|
36955
39367
|
listens.closeAll();
|
|
36956
39368
|
return handler.close();
|
|
@@ -36958,10 +39370,11 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36958
39370
|
};
|
|
36959
39371
|
return entry;
|
|
36960
39372
|
};
|
|
36961
|
-
const serveModern = async (req, res, body, token, as) => {
|
|
36962
|
-
const
|
|
39373
|
+
const serveModern = async (req, res, body, token, as, surface) => {
|
|
39374
|
+
const key = `${surface}:${token.grantId}`;
|
|
39375
|
+
const entry = grants.acquire(key, token.subject, () => createGrantEntry(token, as, surface));
|
|
36963
39376
|
if (!entry) {
|
|
36964
|
-
sendUnauthorized(res, as.issuer);
|
|
39377
|
+
sendUnauthorized(res, as.issuer, surface);
|
|
36965
39378
|
return;
|
|
36966
39379
|
}
|
|
36967
39380
|
entry.auth.zendeskToken = token.zendeskAccessToken;
|
|
@@ -36985,22 +39398,22 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
36985
39398
|
await entry.handle(request, res, body);
|
|
36986
39399
|
} finally {
|
|
36987
39400
|
if (counted) entry.inFlight -= 1;
|
|
36988
|
-
if (entry.revoked && entry.inFlight === 0) await grants.close(
|
|
39401
|
+
if (entry.revoked && entry.inFlight === 0) await grants.close(key, entry);
|
|
36989
39402
|
}
|
|
36990
39403
|
};
|
|
36991
|
-
const serveLegacy = async (req, res, body, token, as) => {
|
|
39404
|
+
const serveLegacy = async (req, res, body, token, as, surface) => {
|
|
36992
39405
|
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
36993
|
-
if (sessionId && await dispatchToSession(req, res, sessionId, token, body)) return;
|
|
39406
|
+
if (sessionId && await dispatchToSession(req, res, sessionId, token, body, surface)) return;
|
|
36994
39407
|
if (req.method !== "POST") {
|
|
36995
39408
|
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
36996
39409
|
return;
|
|
36997
39410
|
}
|
|
36998
|
-
await openSession(req, res, body, token, as);
|
|
39411
|
+
await openSession(req, res, body, token, as, surface);
|
|
36999
39412
|
};
|
|
37000
|
-
const handleMcpRequest = async (req, res, as) => {
|
|
37001
|
-
const token = await authenticate(req, as);
|
|
39413
|
+
const handleMcpRequest = async (req, res, as, surface) => {
|
|
39414
|
+
const token = await authenticate(req, as, surface === "admin" ? as.adminResource : as.resource);
|
|
37002
39415
|
if (!token) {
|
|
37003
|
-
sendUnauthorized(res, as.issuer);
|
|
39416
|
+
sendUnauthorized(res, as.issuer, surface);
|
|
37004
39417
|
return;
|
|
37005
39418
|
}
|
|
37006
39419
|
let body;
|
|
@@ -37013,10 +39426,10 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
37013
39426
|
body = read.value;
|
|
37014
39427
|
}
|
|
37015
39428
|
if (body !== void 0 && !await isLegacyRequest(await toWebRequest(asNodeRequest(req), body), body)) {
|
|
37016
|
-
await serveModern(req, res, body, token, as);
|
|
39429
|
+
await serveModern(req, res, body, token, as, surface);
|
|
37017
39430
|
return;
|
|
37018
39431
|
}
|
|
37019
|
-
await serveLegacy(req, res, body, token, as);
|
|
39432
|
+
await serveLegacy(req, res, body, token, as, surface);
|
|
37020
39433
|
};
|
|
37021
39434
|
const route = async (req, res, as) => {
|
|
37022
39435
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
@@ -37024,6 +39437,10 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
37024
39437
|
sendJson(res, 200, as.protectedResourceMetadata);
|
|
37025
39438
|
return;
|
|
37026
39439
|
}
|
|
39440
|
+
if (req.method === "GET" && url.pathname === ADMIN_PROTECTED_RESOURCE_PATH) {
|
|
39441
|
+
sendJson(res, 200, as.adminProtectedResourceMetadata);
|
|
39442
|
+
return;
|
|
39443
|
+
}
|
|
37027
39444
|
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
37028
39445
|
sendJson(res, 200, {
|
|
37029
39446
|
status: "ok",
|
|
@@ -37032,7 +39449,11 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
37032
39449
|
return;
|
|
37033
39450
|
}
|
|
37034
39451
|
if (url.pathname === "/mcp") {
|
|
37035
|
-
await handleMcpRequest(req, res, as);
|
|
39452
|
+
await handleMcpRequest(req, res, as, "main");
|
|
39453
|
+
return;
|
|
39454
|
+
}
|
|
39455
|
+
if (url.pathname === "/mcp/admin") {
|
|
39456
|
+
await handleMcpRequest(req, res, as, "admin");
|
|
37036
39457
|
return;
|
|
37037
39458
|
}
|
|
37038
39459
|
await as.handle(req, res);
|