@fruggr/zendesk-mcp-server 3.4.0 → 3.5.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 +6 -3
- package/dist/THIRD-PARTY-NOTICES.md +1 -1
- package/dist/{http-DU1dSrPe.js → http-DDdso3Av.js} +31 -5
- package/dist/index.js +2048 -250
- package/dist/pkce-DE_v9NQy.js +60 -0
- package/dist/sbom.cdx.json +7 -7
- package/dist/{token-store-DyO12sbo.js → token-store-CQx_KFe2.js} +16 -7
- package/package.json +4 -4
- package/dist/token-persistence-r0v9RO3q.js +0 -147
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import minpath, { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath, fileURLToPath as urlToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import minproc, { default as process$2 } from "node:process";
|
|
8
|
-
import {
|
|
8
|
+
import { chmod, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
9
11
|
//#region \0rolldown/runtime.js
|
|
10
12
|
var __create$1 = Object.create;
|
|
11
13
|
var __defProp$1 = Object.defineProperty;
|
|
@@ -39,7 +41,7 @@ var __toESM$1 = (mod, isNodeMode, target) => (target = mod != null ? __create$1(
|
|
|
39
41
|
}) : target, mod));
|
|
40
42
|
var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
|
|
41
43
|
//#endregion
|
|
42
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
44
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/util.js
|
|
43
45
|
function getEnumValues(entries) {
|
|
44
46
|
const numericValues = Object.values(entries).filter((v) => typeof v === "number");
|
|
45
47
|
return Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v);
|
|
@@ -591,7 +593,7 @@ function constantCatch(value) {
|
|
|
591
593
|
return fn;
|
|
592
594
|
}
|
|
593
595
|
//#endregion
|
|
594
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
596
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/core.js
|
|
595
597
|
var _a$4;
|
|
596
598
|
/** A special constant with type `never` */
|
|
597
599
|
const NEVER = /*@__PURE__*/ Object.freeze({ status: "aborted" });
|
|
@@ -699,7 +701,7 @@ function config(newConfig) {
|
|
|
699
701
|
return globalConfig;
|
|
700
702
|
}
|
|
701
703
|
//#endregion
|
|
702
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
704
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/doc.js
|
|
703
705
|
var Doc = class {
|
|
704
706
|
constructor(args = [], closed = {}) {
|
|
705
707
|
this.content = [];
|
|
@@ -733,7 +735,7 @@ var Doc = class {
|
|
|
733
735
|
}
|
|
734
736
|
};
|
|
735
737
|
//#endregion
|
|
736
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
738
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/memoizer.js
|
|
737
739
|
var $ZodCyclicError = class extends Error {
|
|
738
740
|
constructor() {
|
|
739
741
|
super(`Cannot parse a reference cycle that closes through a transform`);
|
|
@@ -892,7 +894,7 @@ function bucketFor(state, inst) {
|
|
|
892
894
|
return bucket;
|
|
893
895
|
}
|
|
894
896
|
let handoff;
|
|
895
|
-
const open = [];
|
|
897
|
+
const open$1 = [];
|
|
896
898
|
const memo$2 = {
|
|
897
899
|
alloc(_inst, payload, empty) {
|
|
898
900
|
const bucket = handoff;
|
|
@@ -903,7 +905,7 @@ const memo$2 = {
|
|
|
903
905
|
issues: null
|
|
904
906
|
};
|
|
905
907
|
bucket.set(payload.value, entry);
|
|
906
|
-
open.push(entry);
|
|
908
|
+
open$1.push(entry);
|
|
907
909
|
return empty;
|
|
908
910
|
},
|
|
909
911
|
guard(inst) {
|
|
@@ -969,10 +971,10 @@ const memo$2 = {
|
|
|
969
971
|
return payload;
|
|
970
972
|
}
|
|
971
973
|
handoff = bucket;
|
|
972
|
-
const depth = open.length;
|
|
974
|
+
const depth = open$1.length;
|
|
973
975
|
const result = base(payload, ctx);
|
|
974
976
|
handoff = void 0;
|
|
975
|
-
const entry = open.length > depth ? open.pop() : void 0;
|
|
977
|
+
const entry = open$1.length > depth ? open$1.pop() : void 0;
|
|
976
978
|
if (result instanceof Promise) return result.then((r) => {
|
|
977
979
|
if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
|
|
978
980
|
return r;
|
|
@@ -995,7 +997,7 @@ function isBackEdge(ctx, value) {
|
|
|
995
997
|
return backEdges !== void 0 && isRef(value) && backEdges.has(value);
|
|
996
998
|
}
|
|
997
999
|
//#endregion
|
|
998
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1000
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/regexes.js
|
|
999
1001
|
/**
|
|
1000
1002
|
* @deprecated CUID v1 is deprecated by its authors due to information leakage
|
|
1001
1003
|
* (timestamps embedded in the id). Use {@link cuid2} instead.
|
|
@@ -1070,7 +1072,7 @@ const _null$2 = /^null$/i;
|
|
|
1070
1072
|
const lowercase = /^[^A-Z]*$/;
|
|
1071
1073
|
const uppercase = /^[^a-z]*$/;
|
|
1072
1074
|
//#endregion
|
|
1073
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1075
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/checks.js
|
|
1074
1076
|
const $ZodCheck = /*@__PURE__*/ $constructor("$ZodCheck", (inst, def) => {
|
|
1075
1077
|
var _a;
|
|
1076
1078
|
inst._zod ?? (inst._zod = {});
|
|
@@ -1366,7 +1368,7 @@ const $ZodCheckOverwrite = /*@__PURE__*/ $constructor("$ZodCheckOverwrite", (ins
|
|
|
1366
1368
|
};
|
|
1367
1369
|
});
|
|
1368
1370
|
//#endregion
|
|
1369
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1371
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/errors.js
|
|
1370
1372
|
function _getMessage() {
|
|
1371
1373
|
const internals = this._zod;
|
|
1372
1374
|
internals.message ?? (internals.message = JSON.stringify(internals.def, jsonStringifyReplacer, 2));
|
|
@@ -1482,7 +1484,7 @@ function formatError(error, mapper = (issue) => issue.message) {
|
|
|
1482
1484
|
return fieldErrors;
|
|
1483
1485
|
}
|
|
1484
1486
|
//#endregion
|
|
1485
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1487
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/parse.js
|
|
1486
1488
|
function finalizeParams(callee, params) {
|
|
1487
1489
|
return {
|
|
1488
1490
|
callee: params?.callee ?? callee,
|
|
@@ -1685,14 +1687,14 @@ const _safeDecodeAsync = (_Err) => async (schema, value, _ctx) => {
|
|
|
1685
1687
|
return _safeParseAsync(_Err)(schema, value, _ctx);
|
|
1686
1688
|
};
|
|
1687
1689
|
//#endregion
|
|
1688
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1690
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/versions.js
|
|
1689
1691
|
const version = {
|
|
1690
1692
|
major: 4,
|
|
1691
1693
|
minor: 6,
|
|
1692
|
-
patch:
|
|
1694
|
+
patch: 7
|
|
1693
1695
|
};
|
|
1694
1696
|
//#endregion
|
|
1695
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
1697
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/schemas.js
|
|
1696
1698
|
const $ZodType = /*@__PURE__*/ $constructor("$ZodType", (inst, def) => {
|
|
1697
1699
|
var _a;
|
|
1698
1700
|
inst ?? (inst = {});
|
|
@@ -3230,7 +3232,7 @@ function handleRefineResult(result, payload, input, inst) {
|
|
|
3230
3232
|
}
|
|
3231
3233
|
}
|
|
3232
3234
|
//#endregion
|
|
3233
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
3235
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/compile.js
|
|
3234
3236
|
/** @internal Sentinel the compiled fast path returns when validation fails. */
|
|
3235
3237
|
const INVALID = Symbol.for("zod.compile.invalid");
|
|
3236
3238
|
const FALLBACK_FLAG = Symbol.for("zod.compile.fallback");
|
|
@@ -4715,7 +4717,7 @@ function generateTransformCheck(doc, ctx, schema, accessor) {
|
|
|
4715
4717
|
return accessor;
|
|
4716
4718
|
}
|
|
4717
4719
|
//#endregion
|
|
4718
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
4720
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/locales/en.js
|
|
4719
4721
|
const error = () => {
|
|
4720
4722
|
const Sizable = {
|
|
4721
4723
|
string: {
|
|
@@ -4823,7 +4825,7 @@ function en_default() {
|
|
|
4823
4825
|
return { localeError: error() };
|
|
4824
4826
|
}
|
|
4825
4827
|
//#endregion
|
|
4826
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
4828
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/registries.js
|
|
4827
4829
|
var _a$3;
|
|
4828
4830
|
var $ZodRegistry = class {
|
|
4829
4831
|
constructor() {
|
|
@@ -4870,7 +4872,7 @@ function registry() {
|
|
|
4870
4872
|
(_a$3 = globalThis).__zod_globalRegistry ?? (_a$3.__zod_globalRegistry = registry());
|
|
4871
4873
|
const globalRegistry = globalThis.__zod_globalRegistry;
|
|
4872
4874
|
//#endregion
|
|
4873
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
4875
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/api.js
|
|
4874
4876
|
function snapshotChecks(def) {
|
|
4875
4877
|
if (def.checks) def.checks = [...def.checks];
|
|
4876
4878
|
return def;
|
|
@@ -5399,7 +5401,7 @@ function _check(fn, params) {
|
|
|
5399
5401
|
return ch;
|
|
5400
5402
|
}
|
|
5401
5403
|
//#endregion
|
|
5402
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
5404
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/to-json-schema.js
|
|
5403
5405
|
function assignProps(target, ...sources) {
|
|
5404
5406
|
for (const source of sources) for (const key of Reflect.ownKeys(source)) if (Object.prototype.propertyIsEnumerable.call(source, key)) assignProp(target, key, source[key]);
|
|
5405
5407
|
return target;
|
|
@@ -5843,7 +5845,7 @@ const createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params)
|
|
|
5843
5845
|
return finalize(ctx, schema);
|
|
5844
5846
|
};
|
|
5845
5847
|
//#endregion
|
|
5846
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
5848
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/json-schema-processors.js
|
|
5847
5849
|
const narrowMin = (agg, key, value) => {
|
|
5848
5850
|
if (agg[key] === void 0 || value > agg[key]) agg[key] = value;
|
|
5849
5851
|
};
|
|
@@ -6489,7 +6491,7 @@ function toJSONSchema(input, params) {
|
|
|
6489
6491
|
return finalize(ctx, input);
|
|
6490
6492
|
}
|
|
6491
6493
|
//#endregion
|
|
6492
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
6494
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/compile.js
|
|
6493
6495
|
let compiling = false;
|
|
6494
6496
|
globalConfig.postProcessor = (inst) => {
|
|
6495
6497
|
if (compiling) return;
|
|
@@ -6518,7 +6520,7 @@ globalConfig.postProcessor = (inst) => {
|
|
|
6518
6520
|
inst._zod.run = shim;
|
|
6519
6521
|
};
|
|
6520
6522
|
//#endregion
|
|
6521
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
6523
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/errors.js
|
|
6522
6524
|
const _installedErrorProtos = /* @__PURE__ */ new WeakSet([Object.prototype, Error.prototype]);
|
|
6523
6525
|
function _lazyMethod(proto, key, make) {
|
|
6524
6526
|
Object.defineProperty(proto, key, {
|
|
@@ -6568,7 +6570,7 @@ const initializer = (inst, issues) => {
|
|
|
6568
6570
|
};
|
|
6569
6571
|
const ZodRealError = /*@__PURE__*/ $constructor("ZodError", initializer, void 0, { Parent: Error });
|
|
6570
6572
|
//#endregion
|
|
6571
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
6573
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/parse.js
|
|
6572
6574
|
const parse$10 = /* @__PURE__ */ _parse(ZodRealError);
|
|
6573
6575
|
const parseAsync = /* @__PURE__ */ _parseAsync(ZodRealError);
|
|
6574
6576
|
const safeParse = /* @__PURE__ */ _safeParse(ZodRealError);
|
|
@@ -6582,7 +6584,7 @@ const safeDecode = /* @__PURE__ */ _safeDecode(ZodRealError);
|
|
|
6582
6584
|
const safeEncodeAsync = /* @__PURE__ */ _safeEncodeAsync(ZodRealError);
|
|
6583
6585
|
const safeDecodeAsync = /* @__PURE__ */ _safeDecodeAsync(ZodRealError);
|
|
6584
6586
|
//#endregion
|
|
6585
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
6587
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/schemas.js
|
|
6586
6588
|
function _ensureDefaultLocale() {
|
|
6587
6589
|
if (!globalConfig.localeError) config(en_default());
|
|
6588
6590
|
}
|
|
@@ -7517,7 +7519,7 @@ function preprocess$1(fn, schema) {
|
|
|
7517
7519
|
});
|
|
7518
7520
|
}
|
|
7519
7521
|
//#endregion
|
|
7520
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
7522
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/compat.js
|
|
7521
7523
|
/** @deprecated Use the raw string literal codes instead, e.g. "invalid_type". */
|
|
7522
7524
|
const ZodIssueCode = {
|
|
7523
7525
|
invalid_type: "invalid_type",
|
|
@@ -7536,7 +7538,7 @@ const ZodIssueCode = {
|
|
|
7536
7538
|
var ZodFirstPartyTypeKind;
|
|
7537
7539
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
7538
7540
|
//#endregion
|
|
7539
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
7541
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/iso.js
|
|
7540
7542
|
function datetime(params) {
|
|
7541
7543
|
return /* @__PURE__ */ _isoDateTime(ZodISODateTime, params);
|
|
7542
7544
|
}
|
|
@@ -7544,7 +7546,7 @@ function date(params) {
|
|
|
7544
7546
|
return /* @__PURE__ */ _isoDate(ZodISODate, params);
|
|
7545
7547
|
}
|
|
7546
7548
|
//#endregion
|
|
7547
|
-
//#region node_modules/.pnpm/zod@4.6.
|
|
7549
|
+
//#region node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/coerce.js
|
|
7548
7550
|
function number$1(params) {
|
|
7549
7551
|
return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
|
|
7550
7552
|
}
|
|
@@ -7561,6 +7563,83 @@ const readEnv = (name) => ({
|
|
|
7561
7563
|
value: process.env[name]
|
|
7562
7564
|
});
|
|
7563
7565
|
//#endregion
|
|
7566
|
+
//#region src/utils/durations.ts
|
|
7567
|
+
const MINUTES_PER_UNIT = {
|
|
7568
|
+
w: 10080,
|
|
7569
|
+
d: 1440,
|
|
7570
|
+
h: 60,
|
|
7571
|
+
m: 1
|
|
7572
|
+
};
|
|
7573
|
+
const DURATION = /^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
|
|
7574
|
+
/**
|
|
7575
|
+
* Reads a readable duration (`30m`, `4h`, `2d`, `1w`, `1d4h`) as whole minutes,
|
|
7576
|
+
* case-insensitive. `undefined` for anything else, including a bare number: a
|
|
7577
|
+
* unit-less `4` is ambiguous between minutes and hours.
|
|
7578
|
+
*/
|
|
7579
|
+
const parseDuration = (raw) => {
|
|
7580
|
+
const text = raw.trim().toLowerCase();
|
|
7581
|
+
const match = DURATION.exec(text);
|
|
7582
|
+
if (!match || text === "") return void 0;
|
|
7583
|
+
const [, w, d, h, m] = match;
|
|
7584
|
+
return Number(w ?? 0) * MINUTES_PER_UNIT.w + Number(d ?? 0) * MINUTES_PER_UNIT.d + Number(h ?? 0) * MINUTES_PER_UNIT.h + Number(m ?? 0);
|
|
7585
|
+
};
|
|
7586
|
+
/** Why `raw` was refused, with the spelling `parseDuration` reads. */
|
|
7587
|
+
const notADuration = (raw) => `"${raw}" is not a duration. Use a number and a unit: 30m, 4h, 2d, 1w, or a mix like 1d4h.`;
|
|
7588
|
+
/**
|
|
7589
|
+
* Renders a span of minutes in the same spelling `parseDuration` reads, days
|
|
7590
|
+
* being the largest unit (`2d1h`, `45m`). The sign is dropped: callers say
|
|
7591
|
+
* "overdue" or "due" themselves.
|
|
7592
|
+
*/
|
|
7593
|
+
const formatMinutes = (minutes) => {
|
|
7594
|
+
let rest = Math.abs(Math.round(minutes));
|
|
7595
|
+
const parts = [];
|
|
7596
|
+
for (const [unit, size] of [["d", MINUTES_PER_UNIT.d], ["h", MINUTES_PER_UNIT.h]]) if (rest >= size) {
|
|
7597
|
+
parts.push(`${Math.floor(rest / size)}${unit}`);
|
|
7598
|
+
rest %= size;
|
|
7599
|
+
}
|
|
7600
|
+
if (rest > 0 || parts.length === 0) parts.push(`${rest}m`);
|
|
7601
|
+
return parts.join("");
|
|
7602
|
+
};
|
|
7603
|
+
//#endregion
|
|
7604
|
+
//#region src/utils/sla-thresholds.ts
|
|
7605
|
+
const REPLY_METRICS = /* @__PURE__ */ new Set(["first_reply_time", "next_reply_time"]);
|
|
7606
|
+
const familyOf = (metric) => REPLY_METRICS.has(metric) ? "reply" : "wait";
|
|
7607
|
+
const DEFAULT_SLA_THRESHOLDS = {
|
|
7608
|
+
reply: {
|
|
7609
|
+
warning: 240,
|
|
7610
|
+
critical: 60
|
|
7611
|
+
},
|
|
7612
|
+
wait: {
|
|
7613
|
+
warning: 2880,
|
|
7614
|
+
critical: 480
|
|
7615
|
+
}
|
|
7616
|
+
};
|
|
7617
|
+
/**
|
|
7618
|
+
* Checks a warning/critical pair given as readable durations. Returns the pair
|
|
7619
|
+
* in minutes, or the reason it is refused: a critical window wider than the
|
|
7620
|
+
* warning one would never show a WARNING.
|
|
7621
|
+
*/
|
|
7622
|
+
const toThreshold = (warning, critical) => {
|
|
7623
|
+
const w = parseDuration(warning);
|
|
7624
|
+
const c = parseDuration(critical);
|
|
7625
|
+
if (w === void 0 || c === void 0) return { error: notADuration(w === void 0 ? warning : critical) };
|
|
7626
|
+
if (c > w) return { error: `The critical threshold (${critical}) must not be longer than the warning one (${warning}).` };
|
|
7627
|
+
return { threshold: {
|
|
7628
|
+
warning: w,
|
|
7629
|
+
critical: c
|
|
7630
|
+
} };
|
|
7631
|
+
};
|
|
7632
|
+
/** Reads the `warning,critical` form of the admin variables (`4h,1h`). */
|
|
7633
|
+
const parseThresholdPair = (raw) => {
|
|
7634
|
+
const parts = raw.split(",").map((part) => part.trim());
|
|
7635
|
+
const [warning, critical] = parts;
|
|
7636
|
+
if (parts.length !== 2 || !warning || !critical) return { error: "Expected two durations separated by a comma, warning first: 4h,1h." };
|
|
7637
|
+
return toThreshold(warning, critical);
|
|
7638
|
+
};
|
|
7639
|
+
const formatThreshold = (t) => `warning ${formatMinutes(t.warning)}, critical ${formatMinutes(t.critical)}`;
|
|
7640
|
+
/** One line naming both families' thresholds, as the alerts and the tools report them. */
|
|
7641
|
+
const describeThresholds = (t) => `reply metrics ${formatThreshold(t.reply)}; wait and resolution metrics ${formatThreshold(t.wait)}`;
|
|
7642
|
+
//#endregion
|
|
7564
7643
|
//#region src/utils/startup-error.ts
|
|
7565
7644
|
/**
|
|
7566
7645
|
* Misconfigurations an operator fixes before the server can start. `index.ts`
|
|
@@ -7686,7 +7765,8 @@ const Namespace = _enum([
|
|
|
7686
7765
|
"tickets",
|
|
7687
7766
|
"help_center",
|
|
7688
7767
|
"users",
|
|
7689
|
-
"requests"
|
|
7768
|
+
"requests",
|
|
7769
|
+
"preferences"
|
|
7690
7770
|
]);
|
|
7691
7771
|
/**
|
|
7692
7772
|
* Namespaces exposed when the operator passes no `--namespace` flag.
|
|
@@ -7701,9 +7781,18 @@ const Namespace = _enum([
|
|
|
7701
7781
|
const DEFAULT_NAMESPACES = [
|
|
7702
7782
|
"tickets",
|
|
7703
7783
|
"help_center",
|
|
7704
|
-
"users"
|
|
7784
|
+
"users",
|
|
7785
|
+
"preferences"
|
|
7705
7786
|
];
|
|
7706
7787
|
const Transport = _enum(["stdio", "http"]);
|
|
7788
|
+
const SLA_POLL_RANGE = {
|
|
7789
|
+
min: 60,
|
|
7790
|
+
max: 86400
|
|
7791
|
+
};
|
|
7792
|
+
const SlaThresholdSchema = object({
|
|
7793
|
+
warning: number$2().int().min(0),
|
|
7794
|
+
critical: number$2().int().min(0)
|
|
7795
|
+
});
|
|
7707
7796
|
const ConfigSchema = object({
|
|
7708
7797
|
subdomain: string$2().min(1, "ZENDESK_SUBDOMAIN is required"),
|
|
7709
7798
|
oauthClientId: string$2().min(1),
|
|
@@ -7809,8 +7898,51 @@ const ConfigSchema = object({
|
|
|
7809
7898
|
/** HTTP only: CIMD client ids added to the consent-skip allowlist. */
|
|
7810
7899
|
oauthTrustedClients: array(string$2().url()).default([]),
|
|
7811
7900
|
/** HTTP only: whether the built-in allowlist (claude.ai, ChatGPT) applies. */
|
|
7812
|
-
defaultTrustedClients: boolean$1().default(true)
|
|
7901
|
+
defaultTrustedClients: boolean$1().default(true),
|
|
7902
|
+
/**
|
|
7903
|
+
* Admin default SLA alert thresholds, in minutes before a breach, per metric
|
|
7904
|
+
* family (`SLA_REPLY_THRESHOLDS` / `SLA_WAIT_THRESHOLDS`, `4h,1h` form).
|
|
7905
|
+
* Parsed in `loadConfig`, so a bad value fails at startup naming the knob.
|
|
7906
|
+
*/
|
|
7907
|
+
slaThresholds: object({
|
|
7908
|
+
reply: SlaThresholdSchema,
|
|
7909
|
+
wait: SlaThresholdSchema
|
|
7910
|
+
}).default(DEFAULT_SLA_THRESHOLDS),
|
|
7911
|
+
/**
|
|
7912
|
+
* Whether the server pushes SLA urgency on its own (off by default;
|
|
7913
|
+
* `--sla-push` / `SLA_PUSH=true` turn it on): the `**Urgency**` line in the SLA block of
|
|
7914
|
+
* get_ticket / search_tickets, and the alert resource's polling and update
|
|
7915
|
+
* notifications. Every tool and reading the resource stay available.
|
|
7916
|
+
*/
|
|
7917
|
+
slaPush: boolean$1().default(false),
|
|
7918
|
+
/**
|
|
7919
|
+
* How long one user's SLA scope fetch is reused, in seconds (default 60; 0
|
|
7920
|
+
* turns the cache off). Zendesk charges a 304 revalidation as a full request,
|
|
7921
|
+
* so this lifetime is the only quota saving (PR #387 spike 2).
|
|
7922
|
+
*/
|
|
7923
|
+
slaCacheTtlSeconds: number$2().int().min(0).default(60),
|
|
7924
|
+
/**
|
|
7925
|
+
* Where per-user SLA state (cache, preferences) lives. Unset: `memory` on
|
|
7926
|
+
* HTTP, `disk` on stdio so the stdio processes of one machine share it.
|
|
7927
|
+
*/
|
|
7928
|
+
slaStateStore: _enum(["memory", "disk"]).optional(),
|
|
7929
|
+
/**
|
|
7930
|
+
* Whether each user may set personal SLA alert thresholds with
|
|
7931
|
+
* `configure_sla_alerts` (`SLA_USER_PREFERENCES=off` /
|
|
7932
|
+
* `--no-sla-user-preferences` turn it off: the admin defaults then apply to
|
|
7933
|
+
* everyone).
|
|
7934
|
+
*/
|
|
7935
|
+
slaUserPreferences: boolean$1().default(true),
|
|
7936
|
+
/**
|
|
7937
|
+
* How often, in seconds, a subscribed SLA alert resource is re-checked while
|
|
7938
|
+
* at least one subscription is open (default 300, minimum 60, which keeps a
|
|
7939
|
+
* subscriber well inside Zendesk's rate limit; at most a day, past which
|
|
7940
|
+
* `setInterval` would clamp the delay to 1 ms).
|
|
7941
|
+
*/
|
|
7942
|
+
slaPollSeconds: number$2().int().min(SLA_POLL_RANGE.min).max(SLA_POLL_RANGE.max).default(300)
|
|
7813
7943
|
});
|
|
7944
|
+
/** The store a config resolves to; see `slaStateStore`. */
|
|
7945
|
+
const slaStateStoreOf = (config) => config.slaStateStore ?? (config.transport === "http" ? "memory" : "disk");
|
|
7814
7946
|
const DIGITS_ONLY = /^\d+$/;
|
|
7815
7947
|
const parsePort = (raw, label) => {
|
|
7816
7948
|
if (!DIGITS_ONLY.test(raw)) throw createStartupError(`Invalid ${label} value. Expected an integer 0-65535.`, label.startsWith("--") ? STARTUP_DOCS.invocation : STARTUP_DOCS.environment);
|
|
@@ -7875,7 +8007,14 @@ const CLI_OPTIONS = {
|
|
|
7875
8007
|
"no-topology": { type: "boolean" },
|
|
7876
8008
|
"no-promoted-articles": { type: "boolean" },
|
|
7877
8009
|
dev: { type: "boolean" },
|
|
7878
|
-
"print-tools": { type: "boolean" }
|
|
8010
|
+
"print-tools": { type: "boolean" },
|
|
8011
|
+
"sla-push": { type: "boolean" },
|
|
8012
|
+
"no-sla-user-preferences": { type: "boolean" },
|
|
8013
|
+
"sla-reply-thresholds": { type: "string" },
|
|
8014
|
+
"sla-wait-thresholds": { type: "string" },
|
|
8015
|
+
"sla-cache-ttl-seconds": { type: "string" },
|
|
8016
|
+
"sla-state-store": { type: "string" },
|
|
8017
|
+
"sla-poll-seconds": { type: "string" }
|
|
7879
8018
|
};
|
|
7880
8019
|
new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
|
|
7881
8020
|
const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
@@ -7890,7 +8029,12 @@ const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
|
7890
8029
|
["cors-origin", "corsOrigins"],
|
|
7891
8030
|
["oauth-master-secret-file", "oauthMasterSecretFile"],
|
|
7892
8031
|
["oauth-store", "oauthStore"],
|
|
7893
|
-
["oauth-trusted-client", "oauthTrustedClients"]
|
|
8032
|
+
["oauth-trusted-client", "oauthTrustedClients"],
|
|
8033
|
+
["sla-reply-thresholds", "slaReplyThresholds"],
|
|
8034
|
+
["sla-wait-thresholds", "slaWaitThresholds"],
|
|
8035
|
+
["sla-cache-ttl-seconds", "slaCacheTtlSeconds"],
|
|
8036
|
+
["sla-state-store", "slaStateStore"],
|
|
8037
|
+
["sla-poll-seconds", "slaPollSeconds"]
|
|
7894
8038
|
]);
|
|
7895
8039
|
const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
7896
8040
|
["read-only", { readOnly: true }],
|
|
@@ -7898,7 +8042,9 @@ const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
|
7898
8042
|
["no-promoted-articles", { promotedArticles: false }],
|
|
7899
8043
|
["dev", { dev: true }],
|
|
7900
8044
|
["print-tools", { printTools: true }],
|
|
7901
|
-
["no-default-trusted-clients", { defaultTrustedClients: false }]
|
|
8045
|
+
["no-default-trusted-clients", { defaultTrustedClients: false }],
|
|
8046
|
+
["sla-push", { slaPush: true }],
|
|
8047
|
+
["no-sla-user-preferences", { slaUserPreferences: false }]
|
|
7902
8048
|
]);
|
|
7903
8049
|
const parseStrict = (args) => {
|
|
7904
8050
|
try {
|
|
@@ -7953,6 +8099,59 @@ const resolveOAuthServerSettings = (cli) => ({
|
|
|
7953
8099
|
oauthTrustedClients: [...cli.oauthTrustedClients ?? [], ...splitList(readEnv("OAUTH_TRUSTED_CLIENTS").value)],
|
|
7954
8100
|
defaultTrustedClients: cli.defaultTrustedClients
|
|
7955
8101
|
});
|
|
8102
|
+
const BOOLEAN_WORDS = /* @__PURE__ */ new Map([
|
|
8103
|
+
["true", true],
|
|
8104
|
+
["on", true],
|
|
8105
|
+
["1", true],
|
|
8106
|
+
["false", false],
|
|
8107
|
+
["off", false],
|
|
8108
|
+
["0", false]
|
|
8109
|
+
]);
|
|
8110
|
+
const booleanEnv = (name) => {
|
|
8111
|
+
const env = readNonEmptyEnv(name);
|
|
8112
|
+
if (env.value === void 0) return void 0;
|
|
8113
|
+
const value = BOOLEAN_WORDS.get(env.value.trim().toLowerCase());
|
|
8114
|
+
if (value === void 0) throw createStartupError(`Invalid ${env.name} value. Expected true or false (on/off and 1/0 are accepted too).`, STARTUP_DOCS.environment);
|
|
8115
|
+
return value;
|
|
8116
|
+
};
|
|
8117
|
+
const thresholdSetting = (cliValue, envName, flag, fallback) => {
|
|
8118
|
+
const env = readNonEmptyEnv(envName);
|
|
8119
|
+
const raw = cliValue ?? env.value;
|
|
8120
|
+
if (raw === void 0) return fallback;
|
|
8121
|
+
const parsed = parseThresholdPair(raw);
|
|
8122
|
+
if ("error" in parsed) throw createStartupError(`Invalid ${cliValue === void 0 ? env.name : flag} value. ${parsed.error}`, cliValue === void 0 ? STARTUP_DOCS.environment : STARTUP_DOCS.invocation);
|
|
8123
|
+
return parsed.threshold;
|
|
8124
|
+
};
|
|
8125
|
+
const secondsSetting = (cliValue, envName, flag, range) => {
|
|
8126
|
+
const env = readNonEmptyEnv(envName);
|
|
8127
|
+
const raw = cliValue ?? env.value;
|
|
8128
|
+
if (raw === void 0) return void 0;
|
|
8129
|
+
const fail = (expected) => {
|
|
8130
|
+
throw createStartupError(`Invalid ${cliValue === void 0 ? env.name : flag} value. ${expected}`, cliValue === void 0 ? STARTUP_DOCS.environment : STARTUP_DOCS.invocation);
|
|
8131
|
+
};
|
|
8132
|
+
if (!DIGITS_ONLY.test(raw)) fail("Expected a whole number of seconds.");
|
|
8133
|
+
const seconds = Number(raw);
|
|
8134
|
+
if (range && (seconds < range.min || seconds > range.max)) fail(`Expected ${range.min} to ${range.max} seconds.`);
|
|
8135
|
+
return seconds;
|
|
8136
|
+
};
|
|
8137
|
+
const SLA_STATE_STORES = /* @__PURE__ */ new Set(["memory", "disk"]);
|
|
8138
|
+
const stateStoreSetting = (cliValue) => {
|
|
8139
|
+
const env = readNonEmptyEnv("SLA_STATE_STORE");
|
|
8140
|
+
const raw = cliValue ?? env.value;
|
|
8141
|
+
if (raw === void 0 || SLA_STATE_STORES.has(raw)) return raw;
|
|
8142
|
+
throw createStartupError(`Invalid ${cliValue === void 0 ? env.name : "--sla-state-store"} value. Expected memory or disk.`, cliValue === void 0 ? STARTUP_DOCS.environment : STARTUP_DOCS.invocation);
|
|
8143
|
+
};
|
|
8144
|
+
const resolveSlaSettings = (cli) => ({
|
|
8145
|
+
slaThresholds: {
|
|
8146
|
+
reply: thresholdSetting(cli.slaReplyThresholds, "SLA_REPLY_THRESHOLDS", "--sla-reply-thresholds", DEFAULT_SLA_THRESHOLDS.reply),
|
|
8147
|
+
wait: thresholdSetting(cli.slaWaitThresholds, "SLA_WAIT_THRESHOLDS", "--sla-wait-thresholds", DEFAULT_SLA_THRESHOLDS.wait)
|
|
8148
|
+
},
|
|
8149
|
+
slaPush: cli.slaPush ?? booleanEnv("SLA_PUSH"),
|
|
8150
|
+
slaCacheTtlSeconds: secondsSetting(cli.slaCacheTtlSeconds, "SLA_CACHE_TTL_SECONDS", "--sla-cache-ttl-seconds"),
|
|
8151
|
+
slaStateStore: stateStoreSetting(cli.slaStateStore),
|
|
8152
|
+
slaUserPreferences: cli.slaUserPreferences ?? booleanEnv("SLA_USER_PREFERENCES"),
|
|
8153
|
+
slaPollSeconds: secondsSetting(cli.slaPollSeconds, "SLA_POLL_SECONDS", "--sla-poll-seconds", SLA_POLL_RANGE)
|
|
8154
|
+
});
|
|
7956
8155
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
7957
8156
|
const cli = parseCliArgs(argv);
|
|
7958
8157
|
const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
|
|
@@ -7978,10 +8177,196 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
7978
8177
|
printTools: cli.printTools ?? false,
|
|
7979
8178
|
callbackPort,
|
|
7980
8179
|
...resolveTransportSettings(cli),
|
|
7981
|
-
...resolveOAuthServerSettings(cli)
|
|
8180
|
+
...resolveOAuthServerSettings(cli),
|
|
8181
|
+
...resolveSlaSettings(cli)
|
|
7982
8182
|
});
|
|
7983
8183
|
};
|
|
7984
8184
|
//#endregion
|
|
8185
|
+
//#region src/utils/sla-resource.ts
|
|
8186
|
+
/**
|
|
8187
|
+
* The SLA alert resources. Fixed scheme: unlike the Help Center resources,
|
|
8188
|
+
* nothing about them depends on a deployment.
|
|
8189
|
+
*/
|
|
8190
|
+
const SLA_RESOURCE_SCHEME = "zendesk-sla";
|
|
8191
|
+
const SLA_ALERTS_TEMPLATES = {
|
|
8192
|
+
named: `${SLA_RESOURCE_SCHEME}://alerts/{scope}`,
|
|
8193
|
+
view: `${SLA_RESOURCE_SCHEME}://alerts/views/{view}`,
|
|
8194
|
+
query: `${SLA_RESOURCE_SCHEME}://alerts{?query}`
|
|
8195
|
+
};
|
|
8196
|
+
const NAMED_SCOPES = [
|
|
8197
|
+
"mine",
|
|
8198
|
+
"my_groups",
|
|
8199
|
+
"all"
|
|
8200
|
+
];
|
|
8201
|
+
const namedAlertsUri = (scope) => `${SLA_RESOURCE_SCHEME}://alerts/${scope}`;
|
|
8202
|
+
const DIGITS = /^\d+$/;
|
|
8203
|
+
const VIEW_PATH = /^\/views\/([^/]+)$/;
|
|
8204
|
+
/** Reads an alert resource URI, or `undefined` when it names no alert set. */
|
|
8205
|
+
const parseAlertsUri = (raw) => {
|
|
8206
|
+
let url;
|
|
8207
|
+
try {
|
|
8208
|
+
url = new URL(raw);
|
|
8209
|
+
} catch {
|
|
8210
|
+
return;
|
|
8211
|
+
}
|
|
8212
|
+
if (url.protocol !== `zendesk-sla:` || url.host !== "alerts") return void 0;
|
|
8213
|
+
const path = url.pathname;
|
|
8214
|
+
if (path === "" || path === "/") {
|
|
8215
|
+
const query = url.searchParams.get("query")?.trim();
|
|
8216
|
+
return query ? {
|
|
8217
|
+
kind: "query",
|
|
8218
|
+
query
|
|
8219
|
+
} : void 0;
|
|
8220
|
+
}
|
|
8221
|
+
const named = NAMED_SCOPES.find((scope) => path === `/${scope}`);
|
|
8222
|
+
if (named) return {
|
|
8223
|
+
kind: "scope",
|
|
8224
|
+
scope: named
|
|
8225
|
+
};
|
|
8226
|
+
const view = VIEW_PATH.exec(path)?.[1];
|
|
8227
|
+
if (!view) return void 0;
|
|
8228
|
+
let decoded;
|
|
8229
|
+
try {
|
|
8230
|
+
decoded = decodeURIComponent(view);
|
|
8231
|
+
} catch {
|
|
8232
|
+
return;
|
|
8233
|
+
}
|
|
8234
|
+
return {
|
|
8235
|
+
kind: "view",
|
|
8236
|
+
view: DIGITS.test(decoded) ? Number(decoded) : decoded
|
|
8237
|
+
};
|
|
8238
|
+
};
|
|
8239
|
+
//#endregion
|
|
8240
|
+
//#region src/guidance/instructions.ts
|
|
8241
|
+
/**
|
|
8242
|
+
* URI of the dynamic Help Center topology resource. Single source of truth for
|
|
8243
|
+
* every place that cites it (resource registration, `instructions` blob): the
|
|
8244
|
+
* scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
|
|
8245
|
+
* the path is fixed. Any future Help Center resource should build its URI the
|
|
8246
|
+
* same way so the whole surface follows the configured scheme.
|
|
8247
|
+
*/
|
|
8248
|
+
const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
|
|
8249
|
+
/**
|
|
8250
|
+
* URI template of the pull-only Help Center article resources, built from the
|
|
8251
|
+
* configured scheme exactly like `topologyResourceUri` (`--hc-resource-scheme`,
|
|
8252
|
+
* default `zendesk-hc`). In multi/all brand mode the URI carries a brand
|
|
8253
|
+
* dimension — `<scheme>://brands/{brand}/articles/{id}` — because an article id
|
|
8254
|
+
* alone does not say which brand's Help Center holds it; the brand is named by
|
|
8255
|
+
* id or subdomain, the same idiom the tools' `brand_id` accepts. Unset and
|
|
8256
|
+
* single-brand modes keep `<scheme>://article/{id}`: one Help Center is
|
|
8257
|
+
* addressed, so a brand segment would be noise. The template's `list` callback
|
|
8258
|
+
* enumerates the promoted ("featured") articles (so clients can surface them
|
|
8259
|
+
* for pinning), while any article id can be read on demand.
|
|
8260
|
+
*/
|
|
8261
|
+
const articleResourceUriTemplate = (config) => brandScopedArticles(config) ? `${config.hcResourceScheme}://brands/{brand}/articles/{id}` : `${config.hcResourceScheme}://article/{id}`;
|
|
8262
|
+
/**
|
|
8263
|
+
* Build the concrete resource URI for a single article id, under the configured
|
|
8264
|
+
* scheme. Shares the scheme with the template above so the listed URIs always
|
|
8265
|
+
* match the template the read callback is registered under. In multi/all mode
|
|
8266
|
+
* the brand argument (id or subdomain) is required and fills the {brand} slot.
|
|
8267
|
+
*/
|
|
8268
|
+
const articleResourceUri = (config, id, brand) => brandScopedArticles(config) ? `${config.hcResourceScheme}://brands/${brand}/articles/${id}` : `${config.hcResourceScheme}://article/${id}`;
|
|
8269
|
+
/**
|
|
8270
|
+
* Whether the article resources address one Help Center among several: the
|
|
8271
|
+
* server was started with several brands, or with `all`. Only then does the URI
|
|
8272
|
+
* carry the brand dimension; unset and single-lock modes name exactly one Help
|
|
8273
|
+
* Center implicitly.
|
|
8274
|
+
*/
|
|
8275
|
+
const brandScopedArticles = (config) => {
|
|
8276
|
+
const brandIds = config.brandIds;
|
|
8277
|
+
return brandIds !== void 0 && (brandIds.length > 1 || brandIds[0] === "all");
|
|
8278
|
+
};
|
|
8279
|
+
/**
|
|
8280
|
+
* Whether the `help_center` namespace is active: no `--namespace` filter, or one
|
|
8281
|
+
* that includes it. The shared second half of the two Help Center feature gates
|
|
8282
|
+
* below, so the namespace semantics live in one place.
|
|
8283
|
+
*/
|
|
8284
|
+
const helpCenterNamespaceActive = (config) => !config.namespaces?.length || config.namespaces.includes("help_center");
|
|
8285
|
+
/** Whether `--namespace` and `--tool` leave the tool `name` of `namespace` exposed. */
|
|
8286
|
+
const toolExposed = (config, namespace, name) => (!config.namespaces?.length || config.namespaces.includes(namespace)) && (!config.tools?.length || config.tools.includes(name));
|
|
8287
|
+
/**
|
|
8288
|
+
* Whether the SLA alerting (the `zendesk-sla://alerts/...` resources, their
|
|
8289
|
+
* polling, the instructions line) is on: wherever `list_sla_alerts`, which
|
|
8290
|
+
* renders the same report, is exposed.
|
|
8291
|
+
*/
|
|
8292
|
+
const slaAlertsEnabled = (config) => toolExposed(config, "tickets", "list_sla_alerts");
|
|
8293
|
+
/**
|
|
8294
|
+
* Whether a user can keep personal SLA thresholds: allowed by the operator, and
|
|
8295
|
+
* `configure_sla_alerts`, the only way to change them, exposed. Otherwise the
|
|
8296
|
+
* server defaults apply, and nothing points a user at a tool they cannot call.
|
|
8297
|
+
*/
|
|
8298
|
+
const slaPreferencesEnabled = (config) => config.slaUserPreferences && toolExposed(config, "preferences", "configure_sla_alerts");
|
|
8299
|
+
/**
|
|
8300
|
+
* Whether the Help Center structural context (init instructions + the
|
|
8301
|
+
* topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
|
|
8302
|
+
* feature is enabled (`--no-topology` not set) AND the `help_center` namespace
|
|
8303
|
+
* is active. Shared by the instructions builder and the resource registration in
|
|
8304
|
+
* `server.ts` so both gates stay in sync.
|
|
8305
|
+
*/
|
|
8306
|
+
const helpCenterContextEnabled = (config) => config.topology && helpCenterNamespaceActive(config);
|
|
8307
|
+
/**
|
|
8308
|
+
* Whether the read-by-id article resource (`<scheme>://article/{id}`) should be
|
|
8309
|
+
* registered. Available whenever the `help_center` namespace is active — reading
|
|
8310
|
+
* one article is a cheap, on-demand single fetch with NO preloading, so it is
|
|
8311
|
+
* deliberately NOT gated by the promoted-listing flag: `--no-promoted-articles`
|
|
8312
|
+
* turns off the costly pre-listing (below), never the ability to address a known
|
|
8313
|
+
* article id. Not tied to `--no-topology` either (topology is a separate feature).
|
|
8314
|
+
*/
|
|
8315
|
+
const articleResourceEnabled = (config) => helpCenterNamespaceActive(config);
|
|
8316
|
+
/**
|
|
8317
|
+
* Whether the promoted-article PRE-LISTING is exposed: the resource `list`
|
|
8318
|
+
* callback's scan, which enumerates the promoted articles for `resources/list`.
|
|
8319
|
+
* A client may trigger it unprompted, and it fans out (a capped scan of
|
|
8320
|
+
* `/articles`, per brand in scope), so `--no-promoted-articles` turns it off.
|
|
8321
|
+
* Read-by-id and the explicit `list_promoted_articles` tool stay available.
|
|
8322
|
+
* `!== false` so an omitted flag on a hand-built Config stays default-on.
|
|
8323
|
+
*/
|
|
8324
|
+
const promotedArticlesEnabled = (config) => config.promotedArticles !== false && helpCenterNamespaceActive(config);
|
|
8325
|
+
/**
|
|
8326
|
+
* The one SLA line of the `instructions`: alerts exist, the thresholds in force
|
|
8327
|
+
* by default, and how a user changes theirs. Present whenever the `tickets`
|
|
8328
|
+
* namespace is, push or not: the tool and the resources stay either way.
|
|
8329
|
+
*/
|
|
8330
|
+
const buildSlaInstructions = (config) => {
|
|
8331
|
+
if (!slaAlertsEnabled(config)) return void 0;
|
|
8332
|
+
const subscribe = config.slaPush ? ", subscribable for change notifications" : "";
|
|
8333
|
+
const change = slaPreferencesEnabled(config) ? "each user can set their own with configure_sla_alerts" : "the operator set them for everyone";
|
|
8334
|
+
return `SLA alerts: list_sla_alerts lists the SLA metrics breached or about to breach (your tickets, your groups', all, a view or a search), and the ${SLA_RESOURCE_SCHEME}://alerts/... resources hold the same report${subscribe}. Thresholds in force by default: ${describeThresholds(config.slaThresholds)}; ${change}.`;
|
|
8335
|
+
};
|
|
8336
|
+
/**
|
|
8337
|
+
* The static `instructions` blob sent on `initialize`: the Help Center block
|
|
8338
|
+
* and the SLA line, each present only when its namespace is. Deliberately short
|
|
8339
|
+
* and I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect,
|
|
8340
|
+
* and it stays within a tight token budget. The rich, dynamic topology lives in
|
|
8341
|
+
* the pull-only topology resource (default `zendesk-hc://topology`).
|
|
8342
|
+
*/
|
|
8343
|
+
const buildInstructions = (config) => {
|
|
8344
|
+
const blocks = [buildHelpCenterInstructions(config), buildSlaInstructions(config)].filter((block) => block !== void 0);
|
|
8345
|
+
return blocks.length > 0 ? blocks.join("\n\n") : void 0;
|
|
8346
|
+
};
|
|
8347
|
+
const buildHelpCenterInstructions = (config) => {
|
|
8348
|
+
if (!helpCenterContextEnabled(config)) return void 0;
|
|
8349
|
+
let scope;
|
|
8350
|
+
if (config.brandIds === void 0) scope = `the Zendesk Help Center of "${config.subdomain}"`;
|
|
8351
|
+
else if (config.brandIds.length === 1 && config.brandIds[0] === "all") scope = `all brands of the Zendesk Help Center of "${config.subdomain}"`;
|
|
8352
|
+
else if (config.brandIds.length === 1) scope = `brand ${config.brandIds[0]} of the Zendesk Help Center of "${config.subdomain}"`;
|
|
8353
|
+
else scope = `brands ${config.brandIds.join(", ")} of the Zendesk Help Center of "${config.subdomain}"`;
|
|
8354
|
+
const topologyBrandNote = config.brandIds !== void 0 && (config.brandIds.length > 1 || config.brandIds[0] === "all") ? [`The topology describes ${config.brandIds[0] === "all" ? "the account default brand" : `brand ${config.brandIds[0]}`} only; for another brand, use list_categories / list_sections with its brand_id.`] : [];
|
|
8355
|
+
return [
|
|
8356
|
+
`This MCP server is connected to ${scope}.`,
|
|
8357
|
+
"",
|
|
8358
|
+
`When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
|
|
8359
|
+
"it lists the active locales (and the default one), the category → section tree with IDs,",
|
|
8360
|
+
"the visibility user segments, the permission groups, and your current role.",
|
|
8361
|
+
"Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
|
|
8362
|
+
...topologyBrandNote,
|
|
8363
|
+
"",
|
|
8364
|
+
"It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
|
|
8365
|
+
"user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
|
|
8366
|
+
"or user_segment_id from an existing article (get_article) instead."
|
|
8367
|
+
].join("\n");
|
|
8368
|
+
};
|
|
8369
|
+
//#endregion
|
|
7985
8370
|
//#region node_modules/.pnpm/@modelcontextprotocol+server@2.3.1/node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
|
|
7986
8371
|
var __create = Object.create;
|
|
7987
8372
|
var __defProp = Object.defineProperty;
|
|
@@ -15013,7 +15398,32 @@ function buildInputRequired(spec) {
|
|
|
15013
15398
|
...spec.requestState !== void 0 && { requestState: spec.requestState }
|
|
15014
15399
|
};
|
|
15015
15400
|
}
|
|
15016
|
-
|
|
15401
|
+
/**
|
|
15402
|
+
* Builder for the input-required return value of multi-round-trip handlers,
|
|
15403
|
+
* with per-kind constructors for the embedded requests
|
|
15404
|
+
* (`inputRequired.elicit`, `inputRequired.elicitUrl`,
|
|
15405
|
+
* `inputRequired.createMessage`, `inputRequired.listRoots`).
|
|
15406
|
+
*
|
|
15407
|
+
* @example Write-once tool requesting confirmation
|
|
15408
|
+
* ```ts
|
|
15409
|
+
* server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => {
|
|
15410
|
+
* const confirmationSchema = z.object({ confirm: z.boolean() });
|
|
15411
|
+
* const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema);
|
|
15412
|
+
* if (confirmed?.confirm !== true) {
|
|
15413
|
+
* return inputRequired({
|
|
15414
|
+
* inputRequests: {
|
|
15415
|
+
* confirm: inputRequired.elicit({
|
|
15416
|
+
* message: `Deploy to ${env}?`,
|
|
15417
|
+
* requestedSchema: confirmationSchema
|
|
15418
|
+
* })
|
|
15419
|
+
* }
|
|
15420
|
+
* });
|
|
15421
|
+
* }
|
|
15422
|
+
* return { content: [{ type: 'text', text: `deployed to ${env}` }] };
|
|
15423
|
+
* });
|
|
15424
|
+
* ```
|
|
15425
|
+
*/
|
|
15426
|
+
const inputRequired = Object.assign(buildInputRequired, {
|
|
15017
15427
|
elicit(params) {
|
|
15018
15428
|
try {
|
|
15019
15429
|
return {
|
|
@@ -15043,6 +15453,47 @@ Object.assign(buildInputRequired, {
|
|
|
15043
15453
|
return { method: "roots/list" };
|
|
15044
15454
|
}
|
|
15045
15455
|
});
|
|
15456
|
+
function acceptedContent(responses, key, schema) {
|
|
15457
|
+
const view = inputResponse(responses, key);
|
|
15458
|
+
if (view.kind !== "elicit" || view.action !== "accept" || view.content === void 0) return void 0;
|
|
15459
|
+
if (schema === void 0) return view.content;
|
|
15460
|
+
const outcome = schema["~standard"].validate(view.content);
|
|
15461
|
+
if (outcome instanceof Promise) throw new TypeError("acceptedContent(responses, key, schema) requires a synchronously-validating schema");
|
|
15462
|
+
return outcome.issues === void 0 ? outcome.value : void 0;
|
|
15463
|
+
}
|
|
15464
|
+
/**
|
|
15465
|
+
* Reads one entry of a retried request's `inputResponses`
|
|
15466
|
+
* (`ctx.mcpReq.inputResponses`) as a discriminated view, covering
|
|
15467
|
+
* decline/cancel detection and the non-elicitation response kinds that
|
|
15468
|
+
* {@linkcode acceptedContent} does not surface.
|
|
15469
|
+
*
|
|
15470
|
+
* The values arrive from the client and are not re-validated here — treat
|
|
15471
|
+
* them as untrusted input (validate elicitation content with the
|
|
15472
|
+
* schema-aware {@linkcode acceptedContent} overload where it matters).
|
|
15473
|
+
*/
|
|
15474
|
+
function inputResponse(responses, key) {
|
|
15475
|
+
if (responses === void 0 || typeof responses !== "object" || responses === null) return { kind: "missing" };
|
|
15476
|
+
const entry = responses[key];
|
|
15477
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { kind: "missing" };
|
|
15478
|
+
const candidate = entry;
|
|
15479
|
+
if (candidate["action"] === "accept" || candidate["action"] === "decline" || candidate["action"] === "cancel") {
|
|
15480
|
+
const content = candidate["content"];
|
|
15481
|
+
return {
|
|
15482
|
+
kind: "elicit",
|
|
15483
|
+
action: candidate["action"],
|
|
15484
|
+
...content !== null && typeof content === "object" && !Array.isArray(content) && { content }
|
|
15485
|
+
};
|
|
15486
|
+
}
|
|
15487
|
+
if (Array.isArray(candidate["roots"])) return {
|
|
15488
|
+
kind: "roots",
|
|
15489
|
+
roots: candidate["roots"]
|
|
15490
|
+
};
|
|
15491
|
+
if (typeof candidate["role"] === "string" && candidate["content"] !== void 0) return {
|
|
15492
|
+
kind: "sampling",
|
|
15493
|
+
result: candidate
|
|
15494
|
+
};
|
|
15495
|
+
return { kind: "missing" };
|
|
15496
|
+
}
|
|
15046
15497
|
/**
|
|
15047
15498
|
* The message both multi-round-trip loops emit when the round cap is
|
|
15048
15499
|
* exhausted — the client driver as a typed error, the server-side legacy
|
|
@@ -34271,7 +34722,7 @@ var manipulation_exports = /* @__PURE__ */ __exportAll$1({
|
|
|
34271
34722
|
insertBefore: () => insertBefore,
|
|
34272
34723
|
prepend: () => prepend,
|
|
34273
34724
|
prependTo: () => prependTo,
|
|
34274
|
-
remove: () => remove,
|
|
34725
|
+
remove: () => remove$1,
|
|
34275
34726
|
replaceWith: () => replaceWith,
|
|
34276
34727
|
text: () => text$9,
|
|
34277
34728
|
toString: () => toString$2,
|
|
@@ -34859,7 +35310,7 @@ function insertBefore(target) {
|
|
|
34859
35310
|
* @returns The instance itself.
|
|
34860
35311
|
* @see {@link https://api.jquery.com/remove/}
|
|
34861
35312
|
*/
|
|
34862
|
-
function remove(selector) {
|
|
35313
|
+
function remove$1(selector) {
|
|
34863
35314
|
domEach(selector ? this.filter(selector) : this, (el) => {
|
|
34864
35315
|
removeElement(el);
|
|
34865
35316
|
el.prev = el.next = el.parent = null;
|
|
@@ -63658,7 +64109,7 @@ var require_snapshot_utils = /* @__PURE__ */ __commonJSMin$1(((exports, module)
|
|
|
63658
64109
|
//#endregion
|
|
63659
64110
|
//#region node_modules/.pnpm/undici@7.30.0/node_modules/undici/lib/mock/snapshot-recorder.js
|
|
63660
64111
|
var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin$1(((exports, module) => {
|
|
63661
|
-
const { writeFile, readFile, mkdir } = __require("node:fs/promises");
|
|
64112
|
+
const { writeFile: writeFile$1, readFile: readFile$1, mkdir: mkdir$1 } = __require("node:fs/promises");
|
|
63662
64113
|
const { dirname: dirname$1, resolve: resolve$2 } = __require("node:path");
|
|
63663
64114
|
const { setTimeout: setTimeout$1, clearTimeout: clearTimeout$1 } = __require("node:timers");
|
|
63664
64115
|
const { InvalidArgumentError, UndiciError } = require_errors();
|
|
@@ -63925,7 +64376,7 @@ var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin$1(((exports, modul
|
|
|
63925
64376
|
const path = filePath || this.#snapshotPath;
|
|
63926
64377
|
if (!path) throw new InvalidArgumentError("Snapshot path is required");
|
|
63927
64378
|
try {
|
|
63928
|
-
const data = await readFile(resolve$2(path), "utf8");
|
|
64379
|
+
const data = await readFile$1(resolve$2(path), "utf8");
|
|
63929
64380
|
const parsed = JSON.parse(data);
|
|
63930
64381
|
if (Array.isArray(parsed)) {
|
|
63931
64382
|
this.#snapshots.clear();
|
|
@@ -63946,12 +64397,12 @@ var require_snapshot_recorder = /* @__PURE__ */ __commonJSMin$1(((exports, modul
|
|
|
63946
64397
|
const path = filePath || this.#snapshotPath;
|
|
63947
64398
|
if (!path) throw new InvalidArgumentError("Snapshot path is required");
|
|
63948
64399
|
const resolvedPath = resolve$2(path);
|
|
63949
|
-
await mkdir(dirname$1(resolvedPath), { recursive: true });
|
|
64400
|
+
await mkdir$1(dirname$1(resolvedPath), { recursive: true });
|
|
63950
64401
|
const data = Array.from(this.#snapshots.entries()).map(([hash, snapshot]) => ({
|
|
63951
64402
|
hash,
|
|
63952
64403
|
snapshot
|
|
63953
64404
|
}));
|
|
63954
|
-
await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true });
|
|
64405
|
+
await writeFile$1(resolvedPath, JSON.stringify(data, null, 2), { flush: true });
|
|
63955
64406
|
}
|
|
63956
64407
|
/**
|
|
63957
64408
|
* Clears all recorded snapshots
|
|
@@ -77092,7 +77543,7 @@ function tbody(node, index, parent) {
|
|
|
77092
77543
|
*
|
|
77093
77544
|
* @type {Record<'double' | 'name' | 'single' | 'unquoted', Array<[Array<string>, Array<string>]>>}
|
|
77094
77545
|
*/
|
|
77095
|
-
const constants$
|
|
77546
|
+
const constants$2 = {
|
|
77096
77547
|
name: [[" \n\f\r &/=>".split(""), " \n\f\r \"&'/=>`".split("")], ["\0 \n\f\r \"&'/<=>".split(""), "\0 \n\f\r \"&'/<=>`".split("")]],
|
|
77097
77548
|
unquoted: [[" \n\f\r &>".split(""), "\0 \n\f\r \"&'<=>`".split("")], ["\0 \n\f\r \"&'<=>`".split(""), "\0 \n\f\r \"&'<=>`".split("")]],
|
|
77098
77549
|
single: [["&'".split(""), "\"&'`".split("")], ["\0&'".split(""), "\0\"&'`".split("")]],
|
|
@@ -77177,18 +77628,18 @@ function serializeAttribute(state, key, value) {
|
|
|
77177
77628
|
if (info.overloadedBoolean && (value === info.attribute || value === "")) value = true;
|
|
77178
77629
|
else if ((info.boolean || info.overloadedBoolean) && (typeof value !== "string" || value === info.attribute || value === "")) value = Boolean(value);
|
|
77179
77630
|
if (value === null || value === void 0 || value === false || typeof value === "number" && Number.isNaN(value)) return "";
|
|
77180
|
-
const name = stringifyEntities(info.attribute, Object.assign({}, state.settings.characterReferences, { subset: constants$
|
|
77631
|
+
const name = stringifyEntities(info.attribute, Object.assign({}, state.settings.characterReferences, { subset: constants$2.name[x][y] }));
|
|
77181
77632
|
if (value === true) return name;
|
|
77182
77633
|
value = Array.isArray(value) ? (info.commaSeparated ? stringify$1 : stringify)(value, { padLeft: !state.settings.tightCommaSeparatedLists }) : String(value);
|
|
77183
77634
|
if (state.settings.collapseEmptyAttributes && !value) return name;
|
|
77184
77635
|
if (state.settings.preferUnquoted) result = stringifyEntities(value, Object.assign({}, state.settings.characterReferences, {
|
|
77185
77636
|
attribute: true,
|
|
77186
|
-
subset: constants$
|
|
77637
|
+
subset: constants$2.unquoted[x][y]
|
|
77187
77638
|
}));
|
|
77188
77639
|
if (result !== value) {
|
|
77189
77640
|
if (state.settings.quoteSmart && ccount(value, quote) > ccount(value, state.alternative)) quote = state.alternative;
|
|
77190
77641
|
result = quote + stringifyEntities(value, Object.assign({}, state.settings.characterReferences, {
|
|
77191
|
-
subset: (quote === "'" ? constants$
|
|
77642
|
+
subset: (quote === "'" ? constants$2.single : constants$2.double)[x][y],
|
|
77192
77643
|
attribute: true
|
|
77193
77644
|
})) + quote;
|
|
77194
77645
|
}
|
|
@@ -84315,7 +84766,7 @@ function wbr(state, node) {
|
|
|
84315
84766
|
*/
|
|
84316
84767
|
const nodeHandlers = {
|
|
84317
84768
|
comment,
|
|
84318
|
-
doctype: ignore$
|
|
84769
|
+
doctype: ignore$2,
|
|
84319
84770
|
root: root$2,
|
|
84320
84771
|
text: text$5
|
|
84321
84772
|
};
|
|
@@ -84325,43 +84776,43 @@ const nodeHandlers = {
|
|
|
84325
84776
|
* Each key is an element name, each value is a `Handler`.
|
|
84326
84777
|
*/
|
|
84327
84778
|
const handlers$1 = {
|
|
84328
|
-
applet: ignore$
|
|
84329
|
-
area: ignore$
|
|
84330
|
-
basefont: ignore$
|
|
84331
|
-
bgsound: ignore$
|
|
84332
|
-
caption: ignore$
|
|
84333
|
-
col: ignore$
|
|
84334
|
-
colgroup: ignore$
|
|
84335
|
-
command: ignore$
|
|
84336
|
-
content: ignore$
|
|
84337
|
-
datalist: ignore$
|
|
84338
|
-
dialog: ignore$
|
|
84339
|
-
element: ignore$
|
|
84340
|
-
embed: ignore$
|
|
84341
|
-
frame: ignore$
|
|
84342
|
-
frameset: ignore$
|
|
84343
|
-
isindex: ignore$
|
|
84344
|
-
keygen: ignore$
|
|
84345
|
-
link: ignore$
|
|
84346
|
-
math: ignore$
|
|
84347
|
-
menu: ignore$
|
|
84348
|
-
menuitem: ignore$
|
|
84349
|
-
meta: ignore$
|
|
84350
|
-
nextid: ignore$
|
|
84351
|
-
noembed: ignore$
|
|
84352
|
-
noframes: ignore$
|
|
84353
|
-
optgroup: ignore$
|
|
84354
|
-
option: ignore$
|
|
84355
|
-
param: ignore$
|
|
84356
|
-
script: ignore$
|
|
84357
|
-
shadow: ignore$
|
|
84358
|
-
source: ignore$
|
|
84359
|
-
spacer: ignore$
|
|
84360
|
-
style: ignore$
|
|
84361
|
-
svg: ignore$
|
|
84362
|
-
template: ignore$
|
|
84363
|
-
title: ignore$
|
|
84364
|
-
track: ignore$
|
|
84779
|
+
applet: ignore$2,
|
|
84780
|
+
area: ignore$2,
|
|
84781
|
+
basefont: ignore$2,
|
|
84782
|
+
bgsound: ignore$2,
|
|
84783
|
+
caption: ignore$2,
|
|
84784
|
+
col: ignore$2,
|
|
84785
|
+
colgroup: ignore$2,
|
|
84786
|
+
command: ignore$2,
|
|
84787
|
+
content: ignore$2,
|
|
84788
|
+
datalist: ignore$2,
|
|
84789
|
+
dialog: ignore$2,
|
|
84790
|
+
element: ignore$2,
|
|
84791
|
+
embed: ignore$2,
|
|
84792
|
+
frame: ignore$2,
|
|
84793
|
+
frameset: ignore$2,
|
|
84794
|
+
isindex: ignore$2,
|
|
84795
|
+
keygen: ignore$2,
|
|
84796
|
+
link: ignore$2,
|
|
84797
|
+
math: ignore$2,
|
|
84798
|
+
menu: ignore$2,
|
|
84799
|
+
menuitem: ignore$2,
|
|
84800
|
+
meta: ignore$2,
|
|
84801
|
+
nextid: ignore$2,
|
|
84802
|
+
noembed: ignore$2,
|
|
84803
|
+
noframes: ignore$2,
|
|
84804
|
+
optgroup: ignore$2,
|
|
84805
|
+
option: ignore$2,
|
|
84806
|
+
param: ignore$2,
|
|
84807
|
+
script: ignore$2,
|
|
84808
|
+
shadow: ignore$2,
|
|
84809
|
+
source: ignore$2,
|
|
84810
|
+
spacer: ignore$2,
|
|
84811
|
+
style: ignore$2,
|
|
84812
|
+
svg: ignore$2,
|
|
84813
|
+
template: ignore$2,
|
|
84814
|
+
title: ignore$2,
|
|
84815
|
+
track: ignore$2,
|
|
84365
84816
|
abbr: all$1,
|
|
84366
84817
|
acronym: all$1,
|
|
84367
84818
|
bdi: all$1,
|
|
@@ -84494,7 +84945,7 @@ function flow$2(state, node) {
|
|
|
84494
84945
|
/**
|
|
84495
84946
|
* @returns {undefined}
|
|
84496
84947
|
*/
|
|
84497
|
-
function ignore$
|
|
84948
|
+
function ignore$2() {}
|
|
84498
84949
|
//#endregion
|
|
84499
84950
|
//#region node_modules/.pnpm/hast-util-to-mdast@10.1.2/node_modules/hast-util-to-mdast/lib/state.js
|
|
84500
84951
|
/**
|
|
@@ -90678,7 +91129,7 @@ function check(tokens, before, after) {
|
|
|
90678
91129
|
runs.push(run);
|
|
90679
91130
|
index = end;
|
|
90680
91131
|
}
|
|
90681
|
-
pair(runs, wrong);
|
|
91132
|
+
pair$1(runs, wrong);
|
|
90682
91133
|
index = -1;
|
|
90683
91134
|
while (++index < tokens.length) {
|
|
90684
91135
|
const involved = wrong.get(tokens[index]);
|
|
@@ -90775,7 +91226,7 @@ function improve(items, chosen, mistake, before, after) {
|
|
|
90775
91226
|
* @returns {undefined}
|
|
90776
91227
|
* Nothing, `wrong` is changed.
|
|
90777
91228
|
*/
|
|
90778
|
-
function pair(runs, wrong) {
|
|
91229
|
+
function pair$1(runs, wrong) {
|
|
90779
91230
|
let index = 0;
|
|
90780
91231
|
while (index < runs.length) {
|
|
90781
91232
|
const closer = runs[index];
|
|
@@ -103417,12 +103868,12 @@ const handlers = {
|
|
|
103417
103868
|
tableRow,
|
|
103418
103869
|
text,
|
|
103419
103870
|
thematicBreak,
|
|
103420
|
-
toml: ignore,
|
|
103421
|
-
yaml: ignore,
|
|
103422
|
-
definition: ignore,
|
|
103423
|
-
footnoteDefinition: ignore
|
|
103871
|
+
toml: ignore$1,
|
|
103872
|
+
yaml: ignore$1,
|
|
103873
|
+
definition: ignore$1,
|
|
103874
|
+
footnoteDefinition: ignore$1
|
|
103424
103875
|
};
|
|
103425
|
-
function ignore() {}
|
|
103876
|
+
function ignore$1() {}
|
|
103426
103877
|
//#endregion
|
|
103427
103878
|
//#region node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/footer.js
|
|
103428
103879
|
/**
|
|
@@ -105922,18 +106373,30 @@ const formatSlaMetric = (m, now) => {
|
|
|
105922
106373
|
}
|
|
105923
106374
|
return parts.join("; ");
|
|
105924
106375
|
};
|
|
105925
|
-
const formatSlaBlock = (entry) => {
|
|
106376
|
+
const formatSlaBlock = (entry, urgency = "", heading = "### SLA") => {
|
|
105926
106377
|
if (!entry?.policy_metrics || entry.policy_metrics.length === 0) return "";
|
|
105927
106378
|
const now = Date.now();
|
|
105928
|
-
const lines = [
|
|
106379
|
+
const lines = [heading];
|
|
105929
106380
|
const soonest = entry.policy_metrics.filter(hasPendingDeadline).map((m) => ({
|
|
105930
106381
|
due: m.breach_at,
|
|
105931
106382
|
at: Date.parse(m.breach_at)
|
|
105932
106383
|
})).filter((d) => !Number.isNaN(d.at) && d.at > now).reduce((best, d) => best && best.at <= d.at ? best : d, void 0);
|
|
105933
106384
|
if (soonest) lines.push(`- **Next breach**: ${soonest.due}`);
|
|
106385
|
+
if (urgency) lines.push(urgency);
|
|
105934
106386
|
for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m, now));
|
|
105935
106387
|
return `\n\n${lines.join("\n")}`;
|
|
105936
106388
|
};
|
|
106389
|
+
const formatNextBreachBlock = (metric) => metric ? formatSlaBlock({ policy_metrics: [metric] }, "", "### SLA (next breach only)") : "";
|
|
106390
|
+
/** The next-breaching metric each `/views/{id}/execute` row carries, by ticket id. */
|
|
106391
|
+
const nextBreachByTicket = (rows) => {
|
|
106392
|
+
const byId = /* @__PURE__ */ new Map();
|
|
106393
|
+
for (const row of rows) {
|
|
106394
|
+
const id = row.ticket?.id;
|
|
106395
|
+
const metric = row.ticket?.sla_policy_metric;
|
|
106396
|
+
if (typeof id === "number" && metric) byId.set(id, metric);
|
|
106397
|
+
}
|
|
106398
|
+
return byId;
|
|
106399
|
+
};
|
|
105937
106400
|
const withName = (id, names) => {
|
|
105938
106401
|
const n = Number(id);
|
|
105939
106402
|
if (n === -1) return "System (-1)";
|
|
@@ -106305,106 +106768,6 @@ const createArticleResourcesProvider = (getToken, subdomain, onUnauthorized, res
|
|
|
106305
106768
|
};
|
|
106306
106769
|
};
|
|
106307
106770
|
//#endregion
|
|
106308
|
-
//#region src/guidance/instructions.ts
|
|
106309
|
-
/**
|
|
106310
|
-
* URI of the dynamic Help Center topology resource. Single source of truth for
|
|
106311
|
-
* every place that cites it (resource registration, `instructions` blob): the
|
|
106312
|
-
* scheme comes from the config (`--hc-resource-scheme`, default `zendesk-hc`),
|
|
106313
|
-
* the path is fixed. Any future Help Center resource should build its URI the
|
|
106314
|
-
* same way so the whole surface follows the configured scheme.
|
|
106315
|
-
*/
|
|
106316
|
-
const topologyResourceUri = (config) => `${config.hcResourceScheme}://topology`;
|
|
106317
|
-
/**
|
|
106318
|
-
* URI template of the pull-only Help Center article resources, built from the
|
|
106319
|
-
* configured scheme exactly like `topologyResourceUri` (`--hc-resource-scheme`,
|
|
106320
|
-
* default `zendesk-hc`). In multi/all brand mode the URI carries a brand
|
|
106321
|
-
* dimension — `<scheme>://brands/{brand}/articles/{id}` — because an article id
|
|
106322
|
-
* alone does not say which brand's Help Center holds it; the brand is named by
|
|
106323
|
-
* id or subdomain, the same idiom the tools' `brand_id` accepts. Unset and
|
|
106324
|
-
* single-brand modes keep `<scheme>://article/{id}`: one Help Center is
|
|
106325
|
-
* addressed, so a brand segment would be noise. The template's `list` callback
|
|
106326
|
-
* enumerates the promoted ("featured") articles (so clients can surface them
|
|
106327
|
-
* for pinning), while any article id can be read on demand.
|
|
106328
|
-
*/
|
|
106329
|
-
const articleResourceUriTemplate = (config) => brandScopedArticles(config) ? `${config.hcResourceScheme}://brands/{brand}/articles/{id}` : `${config.hcResourceScheme}://article/{id}`;
|
|
106330
|
-
/**
|
|
106331
|
-
* Build the concrete resource URI for a single article id, under the configured
|
|
106332
|
-
* scheme. Shares the scheme with the template above so the listed URIs always
|
|
106333
|
-
* match the template the read callback is registered under. In multi/all mode
|
|
106334
|
-
* the brand argument (id or subdomain) is required and fills the {brand} slot.
|
|
106335
|
-
*/
|
|
106336
|
-
const articleResourceUri = (config, id, brand) => brandScopedArticles(config) ? `${config.hcResourceScheme}://brands/${brand}/articles/${id}` : `${config.hcResourceScheme}://article/${id}`;
|
|
106337
|
-
/**
|
|
106338
|
-
* Whether the article resources address one Help Center among several: the
|
|
106339
|
-
* server was started with several brands, or with `all`. Only then does the URI
|
|
106340
|
-
* carry the brand dimension; unset and single-lock modes name exactly one Help
|
|
106341
|
-
* Center implicitly.
|
|
106342
|
-
*/
|
|
106343
|
-
const brandScopedArticles = (config) => {
|
|
106344
|
-
const brandIds = config.brandIds;
|
|
106345
|
-
return brandIds !== void 0 && (brandIds.length > 1 || brandIds[0] === "all");
|
|
106346
|
-
};
|
|
106347
|
-
/**
|
|
106348
|
-
* Whether the `help_center` namespace is active: no `--namespace` filter, or one
|
|
106349
|
-
* that includes it. The shared second half of the two Help Center feature gates
|
|
106350
|
-
* below, so the namespace semantics live in one place.
|
|
106351
|
-
*/
|
|
106352
|
-
const helpCenterNamespaceActive = (config) => !config.namespaces?.length || config.namespaces.includes("help_center");
|
|
106353
|
-
/**
|
|
106354
|
-
* Whether the Help Center structural context (init instructions + the
|
|
106355
|
-
* topology resource, default `zendesk-hc://topology`) should be exposed. True only when the
|
|
106356
|
-
* feature is enabled (`--no-topology` not set) AND the `help_center` namespace
|
|
106357
|
-
* is active. Shared by the instructions builder and the resource registration in
|
|
106358
|
-
* `server.ts` so both gates stay in sync.
|
|
106359
|
-
*/
|
|
106360
|
-
const helpCenterContextEnabled = (config) => config.topology && helpCenterNamespaceActive(config);
|
|
106361
|
-
/**
|
|
106362
|
-
* Whether the read-by-id article resource (`<scheme>://article/{id}`) should be
|
|
106363
|
-
* registered. Available whenever the `help_center` namespace is active — reading
|
|
106364
|
-
* one article is a cheap, on-demand single fetch with NO preloading, so it is
|
|
106365
|
-
* deliberately NOT gated by the promoted-listing flag: `--no-promoted-articles`
|
|
106366
|
-
* turns off the costly pre-listing (below), never the ability to address a known
|
|
106367
|
-
* article id. Not tied to `--no-topology` either (topology is a separate feature).
|
|
106368
|
-
*/
|
|
106369
|
-
const articleResourceEnabled = (config) => helpCenterNamespaceActive(config);
|
|
106370
|
-
/**
|
|
106371
|
-
* Whether the promoted-article PRE-LISTING is exposed: the resource `list`
|
|
106372
|
-
* callback's scan, which enumerates the promoted articles for `resources/list`.
|
|
106373
|
-
* A client may trigger it unprompted, and it fans out (a capped scan of
|
|
106374
|
-
* `/articles`, per brand in scope), so `--no-promoted-articles` turns it off.
|
|
106375
|
-
* Read-by-id and the explicit `list_promoted_articles` tool stay available.
|
|
106376
|
-
* `!== false` so an omitted flag on a hand-built Config stays default-on.
|
|
106377
|
-
*/
|
|
106378
|
-
const promotedArticlesEnabled = (config) => config.promotedArticles !== false && helpCenterNamespaceActive(config);
|
|
106379
|
-
/**
|
|
106380
|
-
* The static `instructions` blob sent on `initialize`. Deliberately short and
|
|
106381
|
-
* I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
|
|
106382
|
-
* it stays within a tight token budget. The rich, dynamic topology lives in the
|
|
106383
|
-
* pull-only topology resource (default `zendesk-hc://topology`) referenced here.
|
|
106384
|
-
*/
|
|
106385
|
-
const buildInstructions = (config) => {
|
|
106386
|
-
if (!helpCenterContextEnabled(config)) return void 0;
|
|
106387
|
-
let scope;
|
|
106388
|
-
if (config.brandIds === void 0) scope = `the Zendesk Help Center of "${config.subdomain}"`;
|
|
106389
|
-
else if (config.brandIds.length === 1 && config.brandIds[0] === "all") scope = `all brands of the Zendesk Help Center of "${config.subdomain}"`;
|
|
106390
|
-
else if (config.brandIds.length === 1) scope = `brand ${config.brandIds[0]} of the Zendesk Help Center of "${config.subdomain}"`;
|
|
106391
|
-
else scope = `brands ${config.brandIds.join(", ")} of the Zendesk Help Center of "${config.subdomain}"`;
|
|
106392
|
-
const topologyBrandNote = config.brandIds !== void 0 && (config.brandIds.length > 1 || config.brandIds[0] === "all") ? [`The topology describes ${config.brandIds[0] === "all" ? "the account default brand" : `brand ${config.brandIds[0]}`} only; for another brand, use list_categories / list_sections with its brand_id.`] : [];
|
|
106393
|
-
return [
|
|
106394
|
-
`This MCP server is connected to ${scope}.`,
|
|
106395
|
-
"",
|
|
106396
|
-
`When creating or editing Help Center content, the resource ${topologyResourceUri(config)} is useful context:`,
|
|
106397
|
-
"it lists the active locales (and the default one), the category → section tree with IDs,",
|
|
106398
|
-
"the visibility user segments, the permission groups, and your current role.",
|
|
106399
|
-
"Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
|
|
106400
|
-
...topologyBrandNote,
|
|
106401
|
-
"",
|
|
106402
|
-
"It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
|
|
106403
|
-
"user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
|
|
106404
|
-
"or user_segment_id from an existing article (get_article) instead."
|
|
106405
|
-
].join("\n");
|
|
106406
|
-
};
|
|
106407
|
-
//#endregion
|
|
106408
106771
|
//#region src/guidance/topology.ts
|
|
106409
106772
|
/**
|
|
106410
106773
|
* Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
|
|
@@ -106561,6 +106924,9 @@ const createTopologyProvider = (getToken, subdomain, onUnauthorized, brandIds, r
|
|
|
106561
106924
|
return promise;
|
|
106562
106925
|
} };
|
|
106563
106926
|
};
|
|
106927
|
+
//#endregion
|
|
106928
|
+
//#region src/routing/proxy-schema.ts
|
|
106929
|
+
const writes = (t) => !t.readOnly || t.annotations?.readOnlyHint === false;
|
|
106564
106930
|
const PARAMS_FIELD_DESCRIPTION = "Parameters of the chosen operation, named exactly as its line in \"operation\" lists them (* = required). An unknown name is rejected.";
|
|
106565
106931
|
const summarizeDescription = (description) => {
|
|
106566
106932
|
const idx = description.indexOf(". ");
|
|
@@ -106571,7 +106937,7 @@ const operationSignature = (tool) => {
|
|
|
106571
106937
|
const params = Object.entries(tool.inputSchema.shape).map(([key, field]) => field.safeParse(void 0).success ? key : `${key}*`);
|
|
106572
106938
|
return `${tool.name}(${params.join(", ")})`;
|
|
106573
106939
|
};
|
|
106574
|
-
const buildOperationList = (tools) => tools.map((t) => `- ${operationSignature(t)}: ${summarizeDescription(t.description)}${t
|
|
106940
|
+
const buildOperationList = (tools) => tools.map((t) => `- ${operationSignature(t)}: ${summarizeDescription(t.description)}${writes(t) ? " (write)" : ""}`).join("\n");
|
|
106575
106941
|
const buildOperationFieldDescription = (tools) => `One of the operations below. Each line reads name(parameters): summary, where the parameters are the exact names "params" accepts and * marks a required one; (write) marks an operation that changes data.\n${buildOperationList(tools)}`;
|
|
106576
106942
|
const buildProxyDescription = ({ title, tools, readOnly }) => {
|
|
106577
106943
|
const head = `${readOnly ? "[RO] " : ""}${title}. Specify the operation and its parameters. The "operation" field lists every operation with a one-line summary and the parameter names "params" accepts (* = required); an unknown parameter is rejected with the list of valid ones.`;
|
|
@@ -106629,6 +106995,10 @@ const NAMESPACE_LABELS = {
|
|
|
106629
106995
|
requests: {
|
|
106630
106996
|
toolName: "zendesk_requests",
|
|
106631
106997
|
title: "Zendesk Requests"
|
|
106998
|
+
},
|
|
106999
|
+
preferences: {
|
|
107000
|
+
toolName: "connector_preferences",
|
|
107001
|
+
title: "Connector preferences (this MCP server, not Zendesk)"
|
|
106632
107002
|
}
|
|
106633
107003
|
};
|
|
106634
107004
|
//#endregion
|
|
@@ -108066,6 +108436,126 @@ const createHelpCenterTools = (ctx) => {
|
|
|
108066
108436
|
];
|
|
108067
108437
|
};
|
|
108068
108438
|
//#endregion
|
|
108439
|
+
//#region src/utils/sla-preferences.ts
|
|
108440
|
+
const SLA_FAMILIES = ["reply", "wait"];
|
|
108441
|
+
const FAMILY_LABEL = {
|
|
108442
|
+
reply: "Reply metrics (first_reply_time, next_reply_time)",
|
|
108443
|
+
wait: "Wait and resolution metrics (every other metric)"
|
|
108444
|
+
};
|
|
108445
|
+
/** Personal thresholds where set (and allowed), the server default elsewhere. */
|
|
108446
|
+
const effectiveThresholds = (admin, prefs) => ({
|
|
108447
|
+
reply: prefs?.reply ?? admin.reply,
|
|
108448
|
+
wait: prefs?.wait ?? admin.wait
|
|
108449
|
+
});
|
|
108450
|
+
const isEmptyChange = (change) => Object.values(change).every((value) => value === void 0);
|
|
108451
|
+
const readDuration = (raw, field) => {
|
|
108452
|
+
if (raw === void 0) return void 0;
|
|
108453
|
+
const minutes = parseDuration(raw);
|
|
108454
|
+
return minutes === void 0 ? `${field}: ${notADuration(raw)}` : minutes;
|
|
108455
|
+
};
|
|
108456
|
+
const changeFamily = (family, current, change) => {
|
|
108457
|
+
const warning = readDuration(change[`${family}_warning`], `${family}_warning`);
|
|
108458
|
+
const critical = readDuration(change[`${family}_critical`], `${family}_critical`);
|
|
108459
|
+
if (typeof warning === "string") return { error: warning };
|
|
108460
|
+
if (typeof critical === "string") return { error: critical };
|
|
108461
|
+
const given = warning !== void 0 || critical !== void 0;
|
|
108462
|
+
if (change.reset === family || change.reset === "all") return given ? { error: `Either reset the ${family} thresholds or set them, not both.` } : "reset";
|
|
108463
|
+
if (!given) return "keep";
|
|
108464
|
+
const threshold = {
|
|
108465
|
+
warning: warning ?? current.warning,
|
|
108466
|
+
critical: critical ?? current.critical
|
|
108467
|
+
};
|
|
108468
|
+
if (threshold.critical > threshold.warning) return { error: `The ${family} critical threshold (${formatMinutes(threshold.critical)}) must not be longer than its warning one (${formatMinutes(threshold.warning)}).` };
|
|
108469
|
+
return threshold;
|
|
108470
|
+
};
|
|
108471
|
+
/**
|
|
108472
|
+
* Applies a change to the stored preferences. A half given alone keeps the
|
|
108473
|
+
* other half in force (personal, else server default); a family reset goes
|
|
108474
|
+
* back to the server default, and `all` also forgets the user was introduced. Returns the new preferences, or why the change
|
|
108475
|
+
* is refused.
|
|
108476
|
+
*/
|
|
108477
|
+
const applyPreferenceChange = (admin, prefs, change) => {
|
|
108478
|
+
const current = effectiveThresholds(admin, prefs);
|
|
108479
|
+
const next = change.reset === "all" ? {} : { ...prefs };
|
|
108480
|
+
for (const family of SLA_FAMILIES) {
|
|
108481
|
+
const outcome = changeFamily(family, current[family], change);
|
|
108482
|
+
if (outcome === "reset") delete next[family];
|
|
108483
|
+
else if (outcome === "keep") continue;
|
|
108484
|
+
else if ("error" in outcome) return outcome;
|
|
108485
|
+
else next[family] = outcome;
|
|
108486
|
+
}
|
|
108487
|
+
return { preferences: next };
|
|
108488
|
+
};
|
|
108489
|
+
const sourceOf = (family, prefs, allowed, admin) => allowed && prefs?.[family] ? `your preference (server default: ${formatThreshold(admin[family])})` : "server default";
|
|
108490
|
+
/**
|
|
108491
|
+
* The thresholds in force for the caller, where each comes from, and how they
|
|
108492
|
+
* are applied. Shared by `configure_sla_alerts` and the alert tool's
|
|
108493
|
+
* first-call explanation.
|
|
108494
|
+
*/
|
|
108495
|
+
const describeEffectiveThresholds = (admin, prefs, allowed) => {
|
|
108496
|
+
const inForce = effectiveThresholds(admin, allowed ? prefs : void 0);
|
|
108497
|
+
return [
|
|
108498
|
+
"SLA alert thresholds in force for you:",
|
|
108499
|
+
...SLA_FAMILIES.map((family) => `- ${FAMILY_LABEL[family]}: ${formatThreshold(inForce[family])} — ${sourceOf(family, prefs, allowed, admin)}`),
|
|
108500
|
+
"A running metric turns WARNING when its breach is closer than the warning threshold, CRITICAL when closer than the critical one, and BREACHED once its deadline has passed. Paused and achieved metrics never alert.",
|
|
108501
|
+
allowed ? "Change them with configure_sla_alerts (reply_warning, reply_critical, wait_warning, wait_critical), or go back to the server defaults with reset." : "Personal thresholds are turned off on this server (SLA_USER_PREFERENCES=off): the server defaults apply to everyone."
|
|
108502
|
+
].join("\n");
|
|
108503
|
+
};
|
|
108504
|
+
//#endregion
|
|
108505
|
+
//#region src/tools/preferences.ts
|
|
108506
|
+
/** How every SLA duration parameter is spelled, for their descriptions. */
|
|
108507
|
+
const DURATION_HINT = "A readable duration: 30m, 4h, 2d, 1w, or a mix like 1d4h.";
|
|
108508
|
+
/** The caller's SLA service, or the plain error of a server that wires none. */
|
|
108509
|
+
const requireSla = (sla) => {
|
|
108510
|
+
if (!sla) throw new Error("SLA alerting is not available on this server.");
|
|
108511
|
+
return sla;
|
|
108512
|
+
};
|
|
108513
|
+
const createPreferenceTools = (ctx) => [{
|
|
108514
|
+
name: "configure_sla_alerts",
|
|
108515
|
+
namespace: "preferences",
|
|
108516
|
+
readOnly: true,
|
|
108517
|
+
title: "Configure SLA Alert Thresholds",
|
|
108518
|
+
description: "Read or change your personal SLA alert thresholds: how close to a breach a running SLA metric must be to count as WARNING or CRITICAL. Called with no parameters it changes nothing and reports the thresholds in force for you and where each comes from (your preference or the server default). With parameters it saves your own thresholds per metric family (reply: first_reply_time and next_reply_time; wait: every other metric) on this MCP server — nothing is written to Zendesk — and they then drive list_sla_alerts and the Urgency line of get_ticket / search_tickets for you only; reset goes back to the server defaults. When the operator turned personal thresholds off (SLA_USER_PREFERENCES=off), only the read works and the reply says so.",
|
|
108519
|
+
inputSchema: object({
|
|
108520
|
+
reply_warning: string$2().optional().describe(`Time left before a reply metric (first_reply_time, next_reply_time) breaches at which it turns WARNING. ${DURATION_HINT} Omit to keep the current value.`),
|
|
108521
|
+
reply_critical: string$2().optional().describe(`Time left before a reply metric breaches at which it turns CRITICAL; must not exceed the reply warning threshold. ${DURATION_HINT} Omit to keep the current value.`),
|
|
108522
|
+
wait_warning: string$2().optional().describe(`Time left before a wait or resolution metric (requester_wait_time, agent_work_time and every other non-reply metric) breaches at which it turns WARNING. ${DURATION_HINT} Omit to keep the current value.`),
|
|
108523
|
+
wait_critical: string$2().optional().describe(`Time left before a wait or resolution metric breaches at which it turns CRITICAL; must not exceed the wait warning threshold. ${DURATION_HINT} Omit to keep the current value.`),
|
|
108524
|
+
reset: _enum([
|
|
108525
|
+
"reply",
|
|
108526
|
+
"wait",
|
|
108527
|
+
"all"
|
|
108528
|
+
]).optional().describe("Drop your personal thresholds for one family (\"reply\" or \"wait\") or both (\"all\") and follow the server defaults again; \"all\" also makes your next list_sla_alerts call explain the thresholds again. Cannot be combined with new values for the same family.")
|
|
108529
|
+
}),
|
|
108530
|
+
annotations: {
|
|
108531
|
+
readOnlyHint: false,
|
|
108532
|
+
destructiveHint: false,
|
|
108533
|
+
idempotentHint: true,
|
|
108534
|
+
openWorldHint: false
|
|
108535
|
+
},
|
|
108536
|
+
handler: async (params) => {
|
|
108537
|
+
const sla = requireSla(ctx.sla);
|
|
108538
|
+
const change = params;
|
|
108539
|
+
const prefs = await sla.preferences();
|
|
108540
|
+
const describe = (current) => describeEffectiveThresholds(sla.adminThresholds, current, sla.userPreferencesAllowed);
|
|
108541
|
+
if (isEmptyChange(change)) return { content: [{
|
|
108542
|
+
type: "text",
|
|
108543
|
+
text: describe(prefs)
|
|
108544
|
+
}] };
|
|
108545
|
+
if (!sla.userPreferencesAllowed) return { content: [{
|
|
108546
|
+
type: "text",
|
|
108547
|
+
text: `Nothing was changed.\n\n${describe(prefs)}`
|
|
108548
|
+
}] };
|
|
108549
|
+
const applied = applyPreferenceChange(sla.adminThresholds, prefs, change);
|
|
108550
|
+
if ("error" in applied) throw new Error(`${applied.error} Nothing was changed.`);
|
|
108551
|
+
await sla.savePreferences(applied.preferences);
|
|
108552
|
+
return { content: [{
|
|
108553
|
+
type: "text",
|
|
108554
|
+
text: `Your SLA alert thresholds were saved.\n\n${describe(applied.preferences)}`
|
|
108555
|
+
}] };
|
|
108556
|
+
}
|
|
108557
|
+
}];
|
|
108558
|
+
//#endregion
|
|
108069
108559
|
//#region src/tools/attachments.ts
|
|
108070
108560
|
/**
|
|
108071
108561
|
* File-attachment input, shared by every tool that can carry files on a comment.
|
|
@@ -108638,6 +109128,406 @@ const createSearchTools = (ctx) => {
|
|
|
108638
109128
|
}];
|
|
108639
109129
|
};
|
|
108640
109130
|
//#endregion
|
|
109131
|
+
//#region src/utils/elicitation.ts
|
|
109132
|
+
/**
|
|
109133
|
+
* Whether a client can answer a form elicitation. A bare `elicitation: {}`
|
|
109134
|
+
* predates the modes and counts as form support (spec rule the SDK follows).
|
|
109135
|
+
*/
|
|
109136
|
+
const supportsFormElicitation = (capabilities) => {
|
|
109137
|
+
const elicitation = capabilities?.elicitation;
|
|
109138
|
+
if (!elicitation) return false;
|
|
109139
|
+
return Object.keys(elicitation).length === 0 || "form" in elicitation;
|
|
109140
|
+
};
|
|
109141
|
+
const inputRequiredError = (result) => Object.assign(/* @__PURE__ */ new Error("The tool needs input from the user before it can answer."), {
|
|
109142
|
+
name: "InputRequiredError",
|
|
109143
|
+
inputRequired: result
|
|
109144
|
+
});
|
|
109145
|
+
const isInputRequiredError = (err) => err instanceof Error && err.name === "InputRequiredError" && "inputRequired" in err;
|
|
109146
|
+
//#endregion
|
|
109147
|
+
//#region src/utils/sla-urgency.ts
|
|
109148
|
+
/** Most urgent first: the order alerts are grouped and ranked in. */
|
|
109149
|
+
const URGENCY_LEVELS = [
|
|
109150
|
+
"BREACHED",
|
|
109151
|
+
"CRITICAL",
|
|
109152
|
+
"WARNING"
|
|
109153
|
+
];
|
|
109154
|
+
const MINUTE_MS = 6e4;
|
|
109155
|
+
/**
|
|
109156
|
+
* The urgency of one live SLA metric, or `undefined` when it does not call for
|
|
109157
|
+
* attention: not running (paused, achieved, fulfilled), no readable deadline,
|
|
109158
|
+
* or further away than its family's warning threshold. A deadline already
|
|
109159
|
+
* passed is BREACHED whatever the thresholds.
|
|
109160
|
+
*/
|
|
109161
|
+
const classifyMetric = (m, thresholds, now) => {
|
|
109162
|
+
if (!hasPendingDeadline(m)) return void 0;
|
|
109163
|
+
const minutesLeft = Math.round((Date.parse(m.breach_at) - now) / MINUTE_MS) + 0;
|
|
109164
|
+
const { warning, critical } = thresholds[familyOf(m.metric)];
|
|
109165
|
+
let level;
|
|
109166
|
+
if (minutesLeft < 0) level = "BREACHED";
|
|
109167
|
+
else if (minutesLeft <= critical) level = "CRITICAL";
|
|
109168
|
+
else if (minutesLeft <= warning) level = "WARNING";
|
|
109169
|
+
else return void 0;
|
|
109170
|
+
return {
|
|
109171
|
+
metric: m.metric,
|
|
109172
|
+
level,
|
|
109173
|
+
breachAt: m.breach_at,
|
|
109174
|
+
minutesLeft
|
|
109175
|
+
};
|
|
109176
|
+
};
|
|
109177
|
+
/** Orders by level (BREACHED first), then by the soonest breach. */
|
|
109178
|
+
const compareUrgency = (a, b) => URGENCY_LEVELS.indexOf(a.level) - URGENCY_LEVELS.indexOf(b.level) || Date.parse(a.breachAt) - Date.parse(b.breachAt);
|
|
109179
|
+
/** The single most urgent running metric of a ticket, if any calls for attention. */
|
|
109180
|
+
const ticketUrgency = (entry, thresholds, now) => {
|
|
109181
|
+
let worst;
|
|
109182
|
+
for (const m of entry?.policy_metrics ?? []) {
|
|
109183
|
+
const u = classifyMetric(m, thresholds, now);
|
|
109184
|
+
if (u && (!worst || compareUrgency(u, worst) < 0)) worst = u;
|
|
109185
|
+
}
|
|
109186
|
+
return worst;
|
|
109187
|
+
};
|
|
109188
|
+
/** `due in 45m` or `overdue by 2h`. */
|
|
109189
|
+
const formatDueIn = (u) => u.level === "BREACHED" ? `overdue by ${formatMinutes(u.minutesLeft)}` : `due in ${formatMinutes(u.minutesLeft)}`;
|
|
109190
|
+
/**
|
|
109191
|
+
* The `**Urgency**` line pushed into a ticket's SLA block, or '' when nothing is
|
|
109192
|
+
* urgent, so the caller can pass it on unconditionally.
|
|
109193
|
+
*/
|
|
109194
|
+
const formatUrgencyLine = (u) => u ? `- **Urgency**: ${u.level} — ${u.metric} ${formatDueIn(u)}` : "";
|
|
109195
|
+
//#endregion
|
|
109196
|
+
//#region src/utils/sla-alerts.ts
|
|
109197
|
+
const toAlertTicket = (ticket) => ({
|
|
109198
|
+
id: ticket.id,
|
|
109199
|
+
subject: ticket.subject,
|
|
109200
|
+
status: ticket.status,
|
|
109201
|
+
priority: ticket.priority,
|
|
109202
|
+
metrics: ticket.slas?.policy_metrics ?? []
|
|
109203
|
+
});
|
|
109204
|
+
/** Several fetches into one snapshot, each ticket once (a ticket can sit in two groups). */
|
|
109205
|
+
const mergeSnapshots = (snapshots) => {
|
|
109206
|
+
const byId = /* @__PURE__ */ new Map();
|
|
109207
|
+
for (const snapshot of snapshots) for (const ticket of snapshot.tickets) if (!byId.has(ticket.id)) byId.set(ticket.id, ticket);
|
|
109208
|
+
return {
|
|
109209
|
+
tickets: [...byId.values()],
|
|
109210
|
+
truncated: snapshots.some((s) => s.truncated),
|
|
109211
|
+
unread: snapshots.reduce((sum, s) => sum + s.unread, 0)
|
|
109212
|
+
};
|
|
109213
|
+
};
|
|
109214
|
+
/**
|
|
109215
|
+
* Every running metric that is breached or inside its family's thresholds, one
|
|
109216
|
+
* alert per metric, most urgent level first and soonest breach first within it.
|
|
109217
|
+
*/
|
|
109218
|
+
const selectAlerts = (tickets, thresholds, now, filter) => tickets.flatMap((ticket) => ticket.metrics.flatMap((m) => {
|
|
109219
|
+
const urgency = classifyMetric(m, thresholds, now);
|
|
109220
|
+
return urgency ? [{
|
|
109221
|
+
ticket,
|
|
109222
|
+
urgency
|
|
109223
|
+
}] : [];
|
|
109224
|
+
})).filter(({ urgency }) => urgency.level === "BREACHED" ? filter.includeBreached : filter.withinMinutes === void 0 || urgency.minutesLeft <= filter.withinMinutes).sort((a, b) => URGENCY_LEVELS.indexOf(a.urgency.level) - URGENCY_LEVELS.indexOf(b.urgency.level) || Date.parse(a.urgency.breachAt) - Date.parse(b.urgency.breachAt) || a.ticket.id - b.ticket.id);
|
|
109225
|
+
/**
|
|
109226
|
+
* What a subscriber is notified about: which ticket, which metric, which
|
|
109227
|
+
* level. The minutes counting down are left out on purpose, or every poll
|
|
109228
|
+
* would read as a change.
|
|
109229
|
+
*/
|
|
109230
|
+
const alertFingerprint = (alerts) => alerts.map(({ ticket, urgency }) => `${ticket.id}:${urgency.metric}:${urgency.level}`).sort((a, b) => a.localeCompare(b)).join("|");
|
|
109231
|
+
const formatAlert = ({ ticket, urgency }) => `- #${ticket.id} ${urgency.metric} ${formatDueIn(urgency)} (due ${urgency.breachAt}) — "${ticket.subject}" [${ticket.status}, ${ticket.priority ?? "no priority"}]`;
|
|
109232
|
+
/**
|
|
109233
|
+
* The compact report: a summary line, the thresholds, then one line per alert
|
|
109234
|
+
* grouped by level. Past `maxLines` alerts, the rest are counted, not listed.
|
|
109235
|
+
*/
|
|
109236
|
+
const renderAlerts = (alerts, report) => {
|
|
109237
|
+
const counts = URGENCY_LEVELS.map((level) => `${alerts.filter((a) => a.urgency.level === level).length} ${level.toLowerCase()}`).join(", ");
|
|
109238
|
+
const lines = [`SLA alerts for ${report.scopeLabel}: ${counts} (${report.checked} ticket(s) checked).`, `Thresholds: ${report.thresholds}.`];
|
|
109239
|
+
if (report.truncated) {
|
|
109240
|
+
const howMany = report.unread > 0 ? `${report.unread} more ticket(s)` : "More tickets";
|
|
109241
|
+
lines.push(`Truncated: ${howMany} in this scope were not read. Narrow it with query or view to see them.`);
|
|
109242
|
+
}
|
|
109243
|
+
for (const note of report.notes) lines.push(note);
|
|
109244
|
+
if (alerts.length === 0) {
|
|
109245
|
+
lines.push("", "No running SLA metric is breached or within its thresholds.");
|
|
109246
|
+
return lines.join("\n");
|
|
109247
|
+
}
|
|
109248
|
+
const shown = alerts.slice(0, report.maxLines);
|
|
109249
|
+
for (const level of URGENCY_LEVELS) {
|
|
109250
|
+
const group = shown.filter((a) => a.urgency.level === level);
|
|
109251
|
+
if (group.length > 0) lines.push("", `## ${level}`, ...group.map(formatAlert));
|
|
109252
|
+
}
|
|
109253
|
+
const hidden = alerts.length - shown.length;
|
|
109254
|
+
if (hidden > 0) lines.push("", `${hidden} more alert(s) not listed. Narrow the scope with query or view, or shorten within.`);
|
|
109255
|
+
return lines.join("\n");
|
|
109256
|
+
};
|
|
109257
|
+
//#endregion
|
|
109258
|
+
//#region src/utils/sla-scope.ts
|
|
109259
|
+
const SCOPE_QUERIES = {
|
|
109260
|
+
mine: "assignee:me status<solved",
|
|
109261
|
+
all: "status<solved"
|
|
109262
|
+
};
|
|
109263
|
+
const groupQuery = (groupId) => `group:${groupId} status<solved`;
|
|
109264
|
+
/** The cache key of a scope: same tickets, same key, whoever asks. */
|
|
109265
|
+
const scopeKey = (scope) => {
|
|
109266
|
+
if (scope.kind === "scope") return `scope:${scope.scope}`;
|
|
109267
|
+
if (scope.kind === "view") return `view:${scope.viewId}`;
|
|
109268
|
+
return `query:${scope.query.trim()}`;
|
|
109269
|
+
};
|
|
109270
|
+
const scopeLabel = (scope) => {
|
|
109271
|
+
if (scope.kind === "view") return `view ${scope.viewId}`;
|
|
109272
|
+
if (scope.kind === "query") return `the tickets matching "${scope.query.trim()}"`;
|
|
109273
|
+
return {
|
|
109274
|
+
mine: "your assigned unsolved tickets",
|
|
109275
|
+
my_groups: "your groups' unsolved tickets",
|
|
109276
|
+
all: "all unsolved tickets"
|
|
109277
|
+
}[scope.scope];
|
|
109278
|
+
};
|
|
109279
|
+
const PAGE_SIZE = 100;
|
|
109280
|
+
const readPages = async (fetchPage) => {
|
|
109281
|
+
const tickets = [];
|
|
109282
|
+
let total;
|
|
109283
|
+
let more = true;
|
|
109284
|
+
for (let page = 1; more && page <= 5; page += 1) {
|
|
109285
|
+
const response = await fetchPage(page);
|
|
109286
|
+
tickets.push(...response.tickets);
|
|
109287
|
+
total = response.count ?? total;
|
|
109288
|
+
more = response.next_page != null && response.tickets.length > 0;
|
|
109289
|
+
}
|
|
109290
|
+
return {
|
|
109291
|
+
tickets: tickets.map(toAlertTicket),
|
|
109292
|
+
truncated: more,
|
|
109293
|
+
unread: more ? Math.max((total ?? 0) - tickets.length, 0) : 0
|
|
109294
|
+
};
|
|
109295
|
+
};
|
|
109296
|
+
const pageParams = (page) => ({
|
|
109297
|
+
per_page: String(PAGE_SIZE),
|
|
109298
|
+
page: String(page)
|
|
109299
|
+
});
|
|
109300
|
+
const readSearch = (get, query) => readPages(async (page) => {
|
|
109301
|
+
const response = await get("/search", {
|
|
109302
|
+
query: `type:ticket ${query}`,
|
|
109303
|
+
include: "tickets(slas)",
|
|
109304
|
+
...pageParams(page)
|
|
109305
|
+
});
|
|
109306
|
+
return {
|
|
109307
|
+
...response,
|
|
109308
|
+
tickets: response.results ?? []
|
|
109309
|
+
};
|
|
109310
|
+
});
|
|
109311
|
+
const readView = (get, viewId) => readPages(async (page) => {
|
|
109312
|
+
const response = await get(`/views/${viewId}/tickets`, {
|
|
109313
|
+
include: "slas",
|
|
109314
|
+
...pageParams(page)
|
|
109315
|
+
});
|
|
109316
|
+
return {
|
|
109317
|
+
...response,
|
|
109318
|
+
tickets: response.tickets ?? []
|
|
109319
|
+
};
|
|
109320
|
+
});
|
|
109321
|
+
/** The ids of the groups `userId` belongs to, every page read. */
|
|
109322
|
+
const readGroupIds = async (get, userId) => {
|
|
109323
|
+
const ids = [];
|
|
109324
|
+
let cursor;
|
|
109325
|
+
do {
|
|
109326
|
+
const params = { "page[size]": String(PAGE_SIZE) };
|
|
109327
|
+
if (cursor) params["page[after]"] = cursor;
|
|
109328
|
+
const response = await get(`/users/${userId}/group_memberships`, params);
|
|
109329
|
+
ids.push(...(response.group_memberships ?? []).map((m) => m.group_id));
|
|
109330
|
+
cursor = response.meta?.has_more ? response.meta.after_cursor ?? void 0 : void 0;
|
|
109331
|
+
} while (cursor);
|
|
109332
|
+
return [...new Set(ids)];
|
|
109333
|
+
};
|
|
109334
|
+
/**
|
|
109335
|
+
* Reads the tickets of a scope with their live SLA, bounded by
|
|
109336
|
+
* `MAX_SCOPE_PAGES` per search or view and `MAX_SCOPE_GROUPS` group searches. Every call goes through `get`, so
|
|
109337
|
+
* through the client's retry policy.
|
|
109338
|
+
*/
|
|
109339
|
+
const fetchScope = async (scope, get, userId) => {
|
|
109340
|
+
if (scope.kind === "view") return readView(get, scope.viewId);
|
|
109341
|
+
if (scope.kind === "query") return readSearch(get, scope.query.trim());
|
|
109342
|
+
if (scope.scope !== "my_groups") return readSearch(get, SCOPE_QUERIES[scope.scope]);
|
|
109343
|
+
const groupIds = await readGroupIds(get, await userId());
|
|
109344
|
+
const snapshots = [];
|
|
109345
|
+
for (const id of groupIds.slice(0, 10)) snapshots.push(await readSearch(get, groupQuery(id)));
|
|
109346
|
+
if (groupIds.length > 10) snapshots.push({
|
|
109347
|
+
tickets: [],
|
|
109348
|
+
truncated: true,
|
|
109349
|
+
unread: 0
|
|
109350
|
+
});
|
|
109351
|
+
return mergeSnapshots(snapshots);
|
|
109352
|
+
};
|
|
109353
|
+
/** Where the thresholds in force come from, as the report states it. */
|
|
109354
|
+
const thresholdsSource = (sla, prefs, overridden) => {
|
|
109355
|
+
if (overridden) return "overridden for this call";
|
|
109356
|
+
if (!sla.userPreferencesAllowed) return "server default";
|
|
109357
|
+
return prefs?.reply || prefs?.wait ? "yours; change them with configure_sla_alerts" : "server default; set your own with configure_sla_alerts";
|
|
109358
|
+
};
|
|
109359
|
+
/**
|
|
109360
|
+
* One alert check, as the tool, a resource read and the poller all run it:
|
|
109361
|
+
* the scope read through the caller's cache, the alerts selected and
|
|
109362
|
+
* rendered, and their fingerprint for change detection.
|
|
109363
|
+
*/
|
|
109364
|
+
const checkAlerts = async (input) => {
|
|
109365
|
+
const { sla, get, target } = input;
|
|
109366
|
+
const snapshot = await sla.cached(scopeKey(target), () => fetchScope(target, get, sla.userId));
|
|
109367
|
+
const alerts = selectAlerts(snapshot.tickets, input.thresholds, Date.now(), input.filter);
|
|
109368
|
+
return {
|
|
109369
|
+
fingerprint: alertFingerprint(alerts),
|
|
109370
|
+
text: renderAlerts(alerts, {
|
|
109371
|
+
scopeLabel: scopeLabel(target),
|
|
109372
|
+
checked: snapshot.tickets.length,
|
|
109373
|
+
truncated: snapshot.truncated,
|
|
109374
|
+
unread: snapshot.unread,
|
|
109375
|
+
thresholds: `${describeThresholds(input.thresholds)} (${input.source})`,
|
|
109376
|
+
notes: input.notes,
|
|
109377
|
+
maxLines: 100
|
|
109378
|
+
})
|
|
109379
|
+
};
|
|
109380
|
+
};
|
|
109381
|
+
//#endregion
|
|
109382
|
+
//#region src/utils/sla-alerts-elicitation.ts
|
|
109383
|
+
/** The key the alert tool's one elicitation is asked and answered under. */
|
|
109384
|
+
const ALERTS_ELICITATION_KEY = "sla_alerts";
|
|
109385
|
+
const SCOPE_CHOICES = [
|
|
109386
|
+
{
|
|
109387
|
+
const: "mine",
|
|
109388
|
+
title: "My assigned tickets"
|
|
109389
|
+
},
|
|
109390
|
+
{
|
|
109391
|
+
const: "my_groups",
|
|
109392
|
+
title: "My groups' tickets"
|
|
109393
|
+
},
|
|
109394
|
+
{
|
|
109395
|
+
const: "all",
|
|
109396
|
+
title: "All unsolved tickets"
|
|
109397
|
+
}
|
|
109398
|
+
];
|
|
109399
|
+
const pair = (t) => `${formatMinutes(t.warning)},${formatMinutes(t.critical)}`;
|
|
109400
|
+
/**
|
|
109401
|
+
* The one form the alert tool may show: the scope when none was given, and on
|
|
109402
|
+
* a user's first call the thresholds, explained and editable. `undefined` when
|
|
109403
|
+
* there is nothing to ask.
|
|
109404
|
+
*/
|
|
109405
|
+
const buildAlertsElicitation = (options) => {
|
|
109406
|
+
const { askScope, introduce, thresholds } = options;
|
|
109407
|
+
if (!askScope && !introduce) return void 0;
|
|
109408
|
+
const properties = {};
|
|
109409
|
+
const message = [];
|
|
109410
|
+
if (askScope) {
|
|
109411
|
+
message.push("Which tickets should be checked for SLA alerts?");
|
|
109412
|
+
properties["scope"] = {
|
|
109413
|
+
type: "string",
|
|
109414
|
+
title: "Tickets to check",
|
|
109415
|
+
oneOf: [...SCOPE_CHOICES],
|
|
109416
|
+
default: "mine"
|
|
109417
|
+
};
|
|
109418
|
+
}
|
|
109419
|
+
if (introduce) {
|
|
109420
|
+
message.push(`A running SLA metric is flagged WARNING when its breach is closer than the warning threshold, CRITICAL when closer than the critical one, and BREACHED once overdue. In force for you: reply metrics (first_reply_time, next_reply_time) ${pair(thresholds.reply)}, every other metric ${pair(thresholds.wait)}, as warning,critical. Edits here apply to this call only; configure_sla_alerts keeps them.`);
|
|
109421
|
+
properties["reply_thresholds"] = {
|
|
109422
|
+
type: "string",
|
|
109423
|
+
title: "Reply metrics: warning,critical",
|
|
109424
|
+
default: pair(thresholds.reply)
|
|
109425
|
+
};
|
|
109426
|
+
properties["wait_thresholds"] = {
|
|
109427
|
+
type: "string",
|
|
109428
|
+
title: "Wait and resolution metrics: warning,critical",
|
|
109429
|
+
default: pair(thresholds.wait)
|
|
109430
|
+
};
|
|
109431
|
+
}
|
|
109432
|
+
return {
|
|
109433
|
+
message: message.join("\n\n"),
|
|
109434
|
+
requestedSchema: {
|
|
109435
|
+
type: "object",
|
|
109436
|
+
properties,
|
|
109437
|
+
...askScope ? { required: ["scope"] } : {}
|
|
109438
|
+
}
|
|
109439
|
+
};
|
|
109440
|
+
};
|
|
109441
|
+
const isNamedScope = (value) => SCOPE_CHOICES.some((choice) => choice.const === value);
|
|
109442
|
+
/**
|
|
109443
|
+
* Reads an accepted answer to that form. The content comes from the client
|
|
109444
|
+
* unvalidated, so every field is checked here; a bad one is ignored with a
|
|
109445
|
+
* note rather than failing the whole call.
|
|
109446
|
+
*/
|
|
109447
|
+
const readAlertsAnswer = (content, thresholds) => {
|
|
109448
|
+
const answer = {
|
|
109449
|
+
change: {},
|
|
109450
|
+
notes: []
|
|
109451
|
+
};
|
|
109452
|
+
if (isNamedScope(content["scope"])) answer.scope = content["scope"];
|
|
109453
|
+
for (const family of ["reply", "wait"]) {
|
|
109454
|
+
const raw = content[`${family}_thresholds`];
|
|
109455
|
+
if (typeof raw !== "string" || raw.trim() === pair(thresholds[family])) continue;
|
|
109456
|
+
const parsed = parseThresholdPair(raw);
|
|
109457
|
+
if ("error" in parsed) {
|
|
109458
|
+
answer.notes.push(`Your ${family} thresholds "${raw}" were not applied: ${parsed.error}`);
|
|
109459
|
+
continue;
|
|
109460
|
+
}
|
|
109461
|
+
answer.change[`${family}_warning`] = formatMinutes(parsed.threshold.warning);
|
|
109462
|
+
answer.change[`${family}_critical`] = formatMinutes(parsed.threshold.critical);
|
|
109463
|
+
}
|
|
109464
|
+
return answer;
|
|
109465
|
+
};
|
|
109466
|
+
/**
|
|
109467
|
+
* Applies the per-call `warning` / `critical` overrides to both families. A
|
|
109468
|
+
* pair given together must hold critical <= warning. A half given alone wins:
|
|
109469
|
+
* a lone warning lowers each family's critical to it at most, a lone critical
|
|
109470
|
+
* raises each family's warning to it at least.
|
|
109471
|
+
*/
|
|
109472
|
+
const overrideThresholds = (thresholds, warning, critical) => {
|
|
109473
|
+
if (warning !== void 0 && critical !== void 0) {
|
|
109474
|
+
const both = toThreshold(warning, critical);
|
|
109475
|
+
return "error" in both ? both : { thresholds: {
|
|
109476
|
+
reply: both.threshold,
|
|
109477
|
+
wait: both.threshold
|
|
109478
|
+
} };
|
|
109479
|
+
}
|
|
109480
|
+
const w = warning === void 0 ? void 0 : parseDuration(warning);
|
|
109481
|
+
if (warning !== void 0 && w === void 0) return { error: notADuration(warning) };
|
|
109482
|
+
const c = critical === void 0 ? void 0 : parseDuration(critical);
|
|
109483
|
+
if (critical !== void 0 && c === void 0) return { error: notADuration(critical) };
|
|
109484
|
+
const apply = (t) => ({
|
|
109485
|
+
warning: w ?? Math.max(t.warning, c ?? 0),
|
|
109486
|
+
critical: c ?? Math.min(t.critical, w ?? t.critical)
|
|
109487
|
+
});
|
|
109488
|
+
return { thresholds: {
|
|
109489
|
+
reply: apply(thresholds.reply),
|
|
109490
|
+
wait: apply(thresholds.wait)
|
|
109491
|
+
} };
|
|
109492
|
+
};
|
|
109493
|
+
//#endregion
|
|
109494
|
+
//#region src/utils/ticket-metrics.ts
|
|
109495
|
+
const NOT_RECORDED = "not recorded";
|
|
109496
|
+
const DURATIONS = [
|
|
109497
|
+
["First reply", "reply_time_in_minutes"],
|
|
109498
|
+
["First resolution", "first_resolution_time_in_minutes"],
|
|
109499
|
+
["Full resolution", "full_resolution_time_in_minutes"],
|
|
109500
|
+
["Agent wait", "agent_wait_time_in_minutes"],
|
|
109501
|
+
["Requester wait", "requester_wait_time_in_minutes"],
|
|
109502
|
+
["On hold", "on_hold_time_in_minutes"]
|
|
109503
|
+
];
|
|
109504
|
+
const half = (minutes) => minutes === null ? "not reached yet" : formatMinutes(minutes);
|
|
109505
|
+
const formatPair = (pair) => {
|
|
109506
|
+
if (!pair) return NOT_RECORDED;
|
|
109507
|
+
if (pair.calendar === null && pair.business === null) return "not reached yet";
|
|
109508
|
+
return `calendar ${half(pair.calendar)}, business ${half(pair.business)}`;
|
|
109509
|
+
};
|
|
109510
|
+
const count = (value) => value === void 0 ? NOT_RECORDED : `${value}`;
|
|
109511
|
+
const when = (value) => value ?? "not yet";
|
|
109512
|
+
/**
|
|
109513
|
+
* One ticket's elapsed metrics (`ticket_metric`): durations in calendar and
|
|
109514
|
+
* business minutes, counters and key timestamps. These are facts recorded
|
|
109515
|
+
* whether or not an SLA policy applies, unlike the live SLA countdown.
|
|
109516
|
+
*/
|
|
109517
|
+
const formatTicketMetrics = (id, ticket, metric) => {
|
|
109518
|
+
if (!ticket) return `## Ticket #${id}\n- Not found, or not visible to you.`;
|
|
109519
|
+
const header = `## Ticket #${id}: ${ticket.subject}`;
|
|
109520
|
+
if (!metric) return `${header}\n- No metrics recorded for this ticket.`;
|
|
109521
|
+
return [
|
|
109522
|
+
header,
|
|
109523
|
+
...DURATIONS.map(([label, key]) => `- **${label}**: ${formatPair(metric[key])}`),
|
|
109524
|
+
`- **Reopens**: ${count(metric.reopens)} | **Replies**: ${count(metric.replies)} | **Assignee stations**: ${count(metric.assignee_stations)} | **Group stations**: ${count(metric.group_stations)}`,
|
|
109525
|
+
`- **Created**: ${metric.created_at ?? NOT_RECORDED} | **First assigned**: ${when(metric.initially_assigned_at)} | **Assigned**: ${when(metric.assigned_at)} | **Solved**: ${when(metric.solved_at)}`,
|
|
109526
|
+
`- **Latest comment**: ${when(metric.latest_comment_added_at)} | **Status changed**: ${when(metric.status_updated_at)}`,
|
|
109527
|
+
`- **Last update by requester**: ${when(metric.requester_updated_at)} | **by assignee**: ${when(metric.assignee_updated_at)}`
|
|
109528
|
+
].join("\n");
|
|
109529
|
+
};
|
|
109530
|
+
//#endregion
|
|
108641
109531
|
//#region src/tools/tickets.ts
|
|
108642
109532
|
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
|
|
108643
109533
|
const MAX_RESPONSE_MB = Number.parseFloat((MAX_RESPONSE_BYTES / 1048576).toFixed(2));
|
|
@@ -108980,6 +109870,12 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
|
108980
109870
|
};
|
|
108981
109871
|
const createTicketTools = (ctx) => {
|
|
108982
109872
|
const { subdomain, getToken } = ctx;
|
|
109873
|
+
const urgencyRenderer = async () => {
|
|
109874
|
+
const sla = ctx.sla;
|
|
109875
|
+
if (!sla?.push) return () => "";
|
|
109876
|
+
const thresholds = await sla.thresholds().catch(() => sla.adminThresholds);
|
|
109877
|
+
return (entry) => formatUrgencyLine(ticketUrgency(entry, thresholds, Date.now()));
|
|
109878
|
+
};
|
|
108983
109879
|
const subscriberIdList = (description, max) => {
|
|
108984
109880
|
const ids = array(number$2().int().positive());
|
|
108985
109881
|
return (max === void 0 ? ids : ids.max(max)).optional().describe(description);
|
|
@@ -108990,7 +109886,7 @@ const createTicketTools = (ctx) => {
|
|
|
108990
109886
|
namespace: "tickets",
|
|
108991
109887
|
readOnly: true,
|
|
108992
109888
|
title: "Get Zendesk Ticket",
|
|
108993
|
-
description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. It also lists the followers and email CCs on the ticket, resolved to names, which is how you tell who a ticket update actually reaches: followers are notified of updates including internal notes, email CCs take part in the public correspondence, and being the requester makes someone neither. Both lists come back empty on an account where the \"CCs and followers\" setting is not enabled in Zendesk. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history. The comment thread is appended in one block — the first page of comments Zendesk returns, cut past the response character limit — so on a long ticket read it with list_ticket_comments, which pages the comments and returns the newest first.",
|
|
109889
|
+
description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. It also lists the followers and email CCs on the ticket, resolved to names, which is how you tell who a ticket update actually reaches: followers are notified of updates including internal notes, email CCs take part in the public correspondence, and being the requester makes someone neither. Both lists come back empty on an account where the \"CCs and followers\" setting is not enabled in Zendesk. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. When the operator turned SLA push on, the SLA block also carries an Urgency line naming the most urgent running metric as BREACHED, CRITICAL or WARNING against the caller's alert thresholds. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history. The comment thread is appended in one block — the first page of comments Zendesk returns, cut past the response character limit — so on a long ticket read it with list_ticket_comments, which pages the comments and returns the newest first.",
|
|
108994
109890
|
inputSchema: object({
|
|
108995
109891
|
ticket_id: number$2().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
|
|
108996
109892
|
include_comments: boolean$1().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields. On a long thread prefer list_ticket_comments — this flag appends one unpaginated block, so comments past Zendesk's first page are absent and the rest is cut at the response character limit.")
|
|
@@ -109005,14 +109901,18 @@ const createTicketTools = (ctx) => {
|
|
|
109005
109901
|
const { ticket_id, include_comments } = params;
|
|
109006
109902
|
const token = await getToken();
|
|
109007
109903
|
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
109008
|
-
const [sla, thread] = await Promise.all([
|
|
109009
|
-
|
|
109010
|
-
|
|
109011
|
-
|
|
109904
|
+
const [sla, thread, urgency] = await Promise.all([
|
|
109905
|
+
fetchTicketSla(subdomain, token, ticket),
|
|
109906
|
+
include_comments ? zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
109907
|
+
include: "users",
|
|
109908
|
+
include_inline_images: "true"
|
|
109909
|
+
}) : void 0,
|
|
109910
|
+
urgencyRenderer()
|
|
109911
|
+
]);
|
|
109012
109912
|
const comments = thread?.comments ?? [];
|
|
109013
109913
|
const names = await resolveUserDisplayNames(subdomain, token, [...collectSubscriberIds(ticket), ...comments.map((comment) => comment.author_id)], thread?.users);
|
|
109014
109914
|
const commentsBlock = thread ? `\n\n---\n# Comments\n\n${comments.map((comment) => formatComment(comment, names)).join("\n\n")}` : "";
|
|
109015
|
-
const text = formatTicket(ticket) + formatSlaBlock(sla) + formatSubscribersBlock(ticket, names) + commentsBlock;
|
|
109915
|
+
const text = formatTicket(ticket) + formatSlaBlock(sla, urgency(sla)) + formatSubscribersBlock(ticket, names) + commentsBlock;
|
|
109016
109916
|
const advice = include_comments ? `get_ticket appends the thread as one unpaginated block; read it page by page with list_ticket_comments (ticket_id: ${ticket_id}, sort_order: "desc") to get the newest comments first.` : "get_ticket takes no pagination or filter parameters, so this response cannot be narrowed from the call.";
|
|
109017
109917
|
return { content: [{
|
|
109018
109918
|
type: "text",
|
|
@@ -109148,7 +110048,7 @@ const createTicketTools = (ctx) => {
|
|
|
109148
110048
|
namespace: "tickets",
|
|
109149
110049
|
readOnly: true,
|
|
109150
110050
|
title: "Search Zendesk Tickets",
|
|
109151
|
-
description: "Search tickets using Zendesk query syntax, returning each result with its live SLA state (per-metric stage and breach countdown) when an SLA policy applies. Examples: \"status:open assignee:me\", \"priority:urgent ticket_type:incident\". Returns total count, so queue triage like \"breaching today\" works without a per-ticket fetch.",
|
|
110051
|
+
description: "Search tickets using Zendesk query syntax, returning each result with its live SLA state (per-metric stage and breach countdown) when an SLA policy applies. Examples: \"status:open assignee:me\", \"priority:urgent ticket_type:incident\". Returns total count, so queue triage like \"breaching today\" works without a per-ticket fetch. When the operator turned SLA push on, each SLA block also carries an Urgency line (BREACHED, CRITICAL or WARNING) against the caller's alert thresholds.",
|
|
109152
110052
|
inputSchema: object({
|
|
109153
110053
|
query: string$2().min(1).describe("Zendesk ticket search query — field filters like \"status:open\", \"assignee:me\", \"priority:urgent ticket_type:incident\", combined with free text. A \"type:ticket\" scope is added automatically, so filter the ticket kind with ticket_type: (e.g. ticket_type:incident), never type: (which the API rejects here)."),
|
|
109154
110054
|
per_page: number$2().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
@@ -109163,12 +110063,12 @@ const createTicketTools = (ctx) => {
|
|
|
109163
110063
|
handler: async (params) => {
|
|
109164
110064
|
const { query, per_page, page } = params;
|
|
109165
110065
|
const token = await getToken();
|
|
109166
|
-
const response = await zendeskGet(subdomain, token, "/search", {
|
|
110066
|
+
const [response, urgency] = await Promise.all([zendeskGet(subdomain, token, "/search", {
|
|
109167
110067
|
query: `type:ticket ${query}`,
|
|
109168
110068
|
include: "tickets(slas)",
|
|
109169
110069
|
...buildOffsetParams(per_page, page)
|
|
109170
|
-
});
|
|
109171
|
-
const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas);
|
|
110070
|
+
}), urgencyRenderer()]);
|
|
110071
|
+
const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas, urgency(ticket.slas));
|
|
109172
110072
|
return { content: [{
|
|
109173
110073
|
type: "text",
|
|
109174
110074
|
text: formatList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page))
|
|
@@ -109482,6 +110382,35 @@ const createTicketTools = (ctx) => {
|
|
|
109482
110382
|
}] };
|
|
109483
110383
|
}
|
|
109484
110384
|
},
|
|
110385
|
+
{
|
|
110386
|
+
name: "get_ticket_metrics",
|
|
110387
|
+
namespace: "tickets",
|
|
110388
|
+
readOnly: true,
|
|
110389
|
+
title: "Get Ticket Metrics",
|
|
110390
|
+
description: "Read the elapsed-time metrics (reply, resolution, wait and on-hold times, reopens, replies) of up to 100 tickets in one call. Each ticket gets first reply, first and full resolution, agent wait, requester wait and on-hold times in calendar and business minutes, plus reopen, reply, assignee-station and group-station counts and the key timestamps (assigned, solved, latest comment). These are facts Zendesk records on every ticket, so they are available even on tickets no SLA policy covers; they say how long things took, not how long is left — for the live breach countdown use get_ticket, search_tickets or list_sla_alerts. A duration still running reads \"not reached yet\". Read-only; tickets the caller cannot see are reported as such rather than failing the call.",
|
|
110391
|
+
inputSchema: object({ ticket_ids: array(number$2().int().positive()).min(1).max(100).describe("Ticket IDs to read metrics for, 1 to 100 numeric ids (duplicates are read once), as returned by search_tickets, list_tickets or get_view_tickets. The answer keeps this order.") }),
|
|
110392
|
+
annotations: {
|
|
110393
|
+
readOnlyHint: true,
|
|
110394
|
+
destructiveHint: false,
|
|
110395
|
+
idempotentHint: true,
|
|
110396
|
+
openWorldHint: true
|
|
110397
|
+
},
|
|
110398
|
+
handler: async (params) => {
|
|
110399
|
+
const ids = [...new Set(params.ticket_ids)];
|
|
110400
|
+
const token = await getToken();
|
|
110401
|
+
const response = await zendeskGet(subdomain, token, "/tickets/show_many", {
|
|
110402
|
+
ids: ids.join(","),
|
|
110403
|
+
include: "metric_sets"
|
|
110404
|
+
});
|
|
110405
|
+
const tickets = new Map((response.tickets ?? []).map((t) => [t.id, t]));
|
|
110406
|
+
const metrics = new Map((response.metric_sets ?? []).map((m) => [m.ticket_id, m]));
|
|
110407
|
+
const text = ids.map((id) => formatTicketMetrics(id, tickets.get(id), metrics.get(id))).join("\n\n");
|
|
110408
|
+
return { content: [{
|
|
110409
|
+
type: "text",
|
|
110410
|
+
text: truncateIfNeeded(text, "Pass fewer ticket_ids per call.")
|
|
110411
|
+
}] };
|
|
110412
|
+
}
|
|
110413
|
+
},
|
|
109485
110414
|
{
|
|
109486
110415
|
name: "list_ticket_fields",
|
|
109487
110416
|
namespace: "tickets",
|
|
@@ -109545,7 +110474,7 @@ const createTicketTools = (ctx) => {
|
|
|
109545
110474
|
namespace: "tickets",
|
|
109546
110475
|
readOnly: true,
|
|
109547
110476
|
title: "Get Tickets In A View",
|
|
109548
|
-
description: "Read the tickets inside a Zendesk view, in the view's own configured sort order — the same order the agent sees in the Zendesk UI — which is the natural way to work a named queue like \"Unassigned tickets\" or \"Breaching today\". Accepts the view by title or by numeric id (discover both with list_views); a title is matched case-insensitively against the agent's active views, and on no match the available titles are returned so you can retry in one step. Tickets come back with the same fields as list_tickets and are cursor-paginated
|
|
110477
|
+
description: "Read the tickets inside a Zendesk view, in the view's own configured sort order — the same order the agent sees in the Zendesk UI — which is the natural way to work a named queue like \"Unassigned tickets\" or \"Breaching today\". Accepts the view by title or by numeric id (discover both with list_views); a title is matched case-insensitively against the agent's active views, and on no match the available titles are returned so you can retry in one step. Tickets come back with the same fields as list_tickets and are cursor-paginated. Each ticket an SLA policy covers also shows its next SLA breach: only the one metric closest to breaching, read from the view itself at no extra cost (use get_ticket or search_tickets for every metric). sort_by/sort_order override the view's order when you want a different cut.",
|
|
109549
110478
|
inputSchema: object({
|
|
109550
110479
|
view: union([string$2().min(1), number$2().int().positive()]).describe("The view to read: its exact title as shown in Zendesk (e.g. \"Unassigned tickets\") or its numeric id from list_views. A title is matched case-insensitively against your active views; on no match the tool returns the available titles instead of erroring, so you can retry with a correct one."),
|
|
109551
110480
|
sort_by: string$2().optional().describe("Optional column to sort by, overriding the view's own sort. Must be one of the view's columns (e.g. \"status\", \"priority\", \"updated_at\", or a custom field id); \"subject\" and \"submitter\" are not sortable. Omit to keep the view's configured order."),
|
|
@@ -109582,9 +110511,10 @@ const createTicketTools = (ctx) => {
|
|
|
109582
110511
|
}
|
|
109583
110512
|
const ids = rows.map(extractRowTicketId).filter((id) => typeof id === "number");
|
|
109584
110513
|
const tickets = await hydrateViewTickets(subdomain, token, ids);
|
|
110514
|
+
const nextBreach = nextBreachByTicket(rows);
|
|
109585
110515
|
return { content: [{
|
|
109586
110516
|
type: "text",
|
|
109587
|
-
text: formatList(tickets, formatTicket, {
|
|
110517
|
+
text: formatList(tickets, (ticket) => formatTicket(ticket) + formatNextBreachBlock(nextBreach.get(ticket.id)), {
|
|
109588
110518
|
...meta,
|
|
109589
110519
|
count: tickets.length
|
|
109590
110520
|
})
|
|
@@ -109648,6 +110578,208 @@ const createTicketTools = (ctx) => {
|
|
|
109648
110578
|
];
|
|
109649
110579
|
};
|
|
109650
110580
|
//#endregion
|
|
110581
|
+
//#region src/tools/sla-alerts.ts
|
|
110582
|
+
const parseWithin = (within) => {
|
|
110583
|
+
if (within === void 0) return void 0;
|
|
110584
|
+
const minutes = parseDuration(within);
|
|
110585
|
+
if (minutes === void 0) throw new Error(`within: "${within}" is not a duration. ${DURATION_HINT}`);
|
|
110586
|
+
return minutes;
|
|
110587
|
+
};
|
|
110588
|
+
/**
|
|
110589
|
+
* The one form the tool may show — the scope when none was given, the
|
|
110590
|
+
* thresholds on a user's first call — and what came of it. Asking throws the
|
|
110591
|
+
* SDK's input-required signal; a client that cannot answer forms gets the
|
|
110592
|
+
* thresholds explained in the reply instead. Thresholds edited in the form
|
|
110593
|
+
* apply to this call only: keeping them is configure_sla_alerts' job. The one
|
|
110594
|
+
* write is the marker that the user was introduced, so it happens once.
|
|
110595
|
+
*/
|
|
110596
|
+
const settleForm = async (sla, call, askScope) => {
|
|
110597
|
+
const prefs = sla.userPreferencesAllowed ? await sla.preferences() : void 0;
|
|
110598
|
+
const introduce = sla.userPreferencesAllowed && !prefs?.introduced;
|
|
110599
|
+
const notes = [];
|
|
110600
|
+
let picked;
|
|
110601
|
+
let edited;
|
|
110602
|
+
const form = buildAlertsElicitation({
|
|
110603
|
+
askScope,
|
|
110604
|
+
introduce,
|
|
110605
|
+
thresholds: await sla.thresholds()
|
|
110606
|
+
});
|
|
110607
|
+
const answered = call?.inputResponses?.[ALERTS_ELICITATION_KEY] !== void 0;
|
|
110608
|
+
if (form && call?.canElicit && !answered) throw inputRequiredError(inputRequired({ inputRequests: { [ALERTS_ELICITATION_KEY]: inputRequired.elicit(form) } }));
|
|
110609
|
+
const content = answered ? acceptedContent(call?.inputResponses, ALERTS_ELICITATION_KEY) : void 0;
|
|
110610
|
+
if (content) {
|
|
110611
|
+
const answer = readAlertsAnswer(content, await sla.thresholds());
|
|
110612
|
+
picked = answer.scope;
|
|
110613
|
+
notes.push(...answer.notes);
|
|
110614
|
+
if (!isEmptyChange(answer.change)) {
|
|
110615
|
+
const applied = applyPreferenceChange(sla.adminThresholds, prefs, answer.change);
|
|
110616
|
+
if ("preferences" in applied) {
|
|
110617
|
+
edited = effectiveThresholds(sla.adminThresholds, applied.preferences);
|
|
110618
|
+
notes.push(keepEditedNote(answer.change));
|
|
110619
|
+
} else notes.push(`Your edited thresholds were not applied: ${applied.error}`);
|
|
110620
|
+
}
|
|
110621
|
+
} else if (introduce && !answered) notes.push("", describeEffectiveThresholds(sla.adminThresholds, prefs, true), "");
|
|
110622
|
+
if (introduce) await sla.savePreferences({
|
|
110623
|
+
...prefs,
|
|
110624
|
+
introduced: true
|
|
110625
|
+
});
|
|
110626
|
+
return {
|
|
110627
|
+
picked,
|
|
110628
|
+
notes,
|
|
110629
|
+
prefs,
|
|
110630
|
+
edited
|
|
110631
|
+
};
|
|
110632
|
+
};
|
|
110633
|
+
const keepEditedNote = (change) => {
|
|
110634
|
+
return `The thresholds you edited apply to this call only. To keep them, call configure_sla_alerts with ${Object.entries(change).filter(([, value]) => value !== void 0).map(([key, value]) => `${key}: "${value}"`).join(", ")}.`;
|
|
110635
|
+
};
|
|
110636
|
+
const createSlaAlertTools = (ctx) => {
|
|
110637
|
+
const { subdomain, getToken } = ctx;
|
|
110638
|
+
const resolveNamedScope = (params, picked, notes) => {
|
|
110639
|
+
if (params.query !== void 0) return {
|
|
110640
|
+
kind: "query",
|
|
110641
|
+
query: params.query
|
|
110642
|
+
};
|
|
110643
|
+
if (params.scope) return {
|
|
110644
|
+
kind: "scope",
|
|
110645
|
+
scope: params.scope
|
|
110646
|
+
};
|
|
110647
|
+
if (picked) return {
|
|
110648
|
+
kind: "scope",
|
|
110649
|
+
scope: picked
|
|
110650
|
+
};
|
|
110651
|
+
notes.push("No scope was given, so this checks your assigned tickets (scope \"mine\"). Pass scope, view or query for another set.");
|
|
110652
|
+
return {
|
|
110653
|
+
kind: "scope",
|
|
110654
|
+
scope: "mine"
|
|
110655
|
+
};
|
|
110656
|
+
};
|
|
110657
|
+
const resolveView = async (token, view) => {
|
|
110658
|
+
const resolved = await resolveViewId(subdomain, token, view);
|
|
110659
|
+
if ("id" in resolved) return {
|
|
110660
|
+
kind: "view",
|
|
110661
|
+
viewId: resolved.id
|
|
110662
|
+
};
|
|
110663
|
+
return resolved.available.length > 0 ? `No active view matches "${view}". Available views: ${resolved.available.join(", ")}.` : `No active view matches "${view}", and no active views were found for this agent.`;
|
|
110664
|
+
};
|
|
110665
|
+
return [{
|
|
110666
|
+
name: "list_sla_alerts",
|
|
110667
|
+
namespace: "tickets",
|
|
110668
|
+
readOnly: true,
|
|
110669
|
+
title: "List SLA Alerts",
|
|
110670
|
+
description: "List the SLA metrics that are breached or about to breach across a set of tickets, most urgent first. Pick the set with exactly one of scope (mine, my_groups or all unsolved tickets), view (a Zendesk view by title or id) or query (any Zendesk ticket search). Each running metric that is breached, or closer to its breach than its CRITICAL or WARNING threshold, is one line under BREACHED, CRITICAL or WARNING, soonest breach first, with the ticket id, metric, time left or overdue, subject, status and priority; paused and achieved metrics never appear. Thresholds are per metric family (reply metrics vs every other metric), the caller's own when set with configure_sla_alerts, else the server's; warning and critical override them for this call only. When no set is given the client may be asked which one, otherwise it defaults to the caller's own tickets and says so; on a user's first call the thresholds are shown too (in that form, or in the reply), and thresholds edited there apply to this call only. Never changes Zendesk; its one side effect is remembering, in this connector's own per-user state, that the user has been shown the thresholds. Results are cached per user for about a minute, and a set larger than a few hundred tickets is read in part, with a notice. For one ticket's full SLA state use get_ticket; for elapsed times use get_ticket_metrics.",
|
|
110671
|
+
inputSchema: object({
|
|
110672
|
+
scope: _enum([
|
|
110673
|
+
"mine",
|
|
110674
|
+
"my_groups",
|
|
110675
|
+
"all"
|
|
110676
|
+
]).optional().describe("A named set of unsolved tickets: \"mine\" (assigned to you), \"my_groups\" (in any group you belong to) or \"all\" (the whole account, as far as your role sees it). Use instead of view or query."),
|
|
110677
|
+
view: union([string$2().min(1), number$2().int().positive()]).optional().describe("A Zendesk view to check, by its exact title (matched case-insensitively) or numeric id, as listed by list_views. Use instead of scope or query."),
|
|
110678
|
+
query: string$2().min(1).optional().describe("A Zendesk ticket search query selecting the tickets to check, e.g. \"status<solved priority:urgent\" or \"organization:acme status<solved\"; \"type:ticket\" is added automatically. Use instead of scope or view."),
|
|
110679
|
+
within: string$2().optional().describe(`Only list alerts breaching within this time from now (breached ones aside, see include_breached). ${DURATION_HINT} Omit to list everything inside the thresholds.`),
|
|
110680
|
+
include_breached: boolean$1().default(true).describe("Whether to list metrics already past their deadline (BREACHED). Defaults to true; set false to see only what can still be saved."),
|
|
110681
|
+
warning: string$2().optional().describe(`Override the WARNING threshold of every metric family for this call only: how much time left before a breach counts as a warning. Given alone, a family's critical threshold is lowered to it where longer. ${DURATION_HINT}`),
|
|
110682
|
+
critical: string$2().optional().describe(`Override the CRITICAL threshold of every metric family for this call only. Given alone, a family's warning threshold is raised to it where shorter; given with warning, it must not exceed it. ${DURATION_HINT}`)
|
|
110683
|
+
}),
|
|
110684
|
+
annotations: {
|
|
110685
|
+
readOnlyHint: true,
|
|
110686
|
+
destructiveHint: false,
|
|
110687
|
+
idempotentHint: true,
|
|
110688
|
+
openWorldHint: true
|
|
110689
|
+
},
|
|
110690
|
+
handler: async (rawParams, call) => {
|
|
110691
|
+
const params = rawParams;
|
|
110692
|
+
const sla = requireSla(ctx.sla);
|
|
110693
|
+
const given = [
|
|
110694
|
+
params.scope,
|
|
110695
|
+
params.view,
|
|
110696
|
+
params.query
|
|
110697
|
+
].filter((v) => v !== void 0);
|
|
110698
|
+
if (given.length > 1) throw new Error("Pass only one of scope, view or query.");
|
|
110699
|
+
const withinMinutes = parseWithin(params.within);
|
|
110700
|
+
const overridden = params.warning !== void 0 || params.critical !== void 0;
|
|
110701
|
+
const checked = overrideThresholds(await sla.thresholds(), params.warning, params.critical);
|
|
110702
|
+
if ("error" in checked) throw new Error(checked.error);
|
|
110703
|
+
const token = await getToken();
|
|
110704
|
+
const view = params.view === void 0 ? void 0 : await resolveView(token, params.view);
|
|
110705
|
+
if (typeof view === "string") return { content: [{
|
|
110706
|
+
type: "text",
|
|
110707
|
+
text: view
|
|
110708
|
+
}] };
|
|
110709
|
+
const { picked, notes, prefs, edited } = await settleForm(sla, call, given.length === 0);
|
|
110710
|
+
const override = edited ? overrideThresholds(edited, params.warning, params.critical) : checked;
|
|
110711
|
+
if ("error" in override) throw new Error(override.error);
|
|
110712
|
+
const scope = view ?? resolveNamedScope(params, picked, notes);
|
|
110713
|
+
const { text } = await checkAlerts({
|
|
110714
|
+
sla,
|
|
110715
|
+
get: (path, query) => zendeskGet(subdomain, token, path, query),
|
|
110716
|
+
target: scope,
|
|
110717
|
+
thresholds: override.thresholds,
|
|
110718
|
+
source: thresholdsSource(sla, prefs, overridden || edited !== void 0),
|
|
110719
|
+
filter: {
|
|
110720
|
+
withinMinutes,
|
|
110721
|
+
includeBreached: params.include_breached
|
|
110722
|
+
},
|
|
110723
|
+
notes
|
|
110724
|
+
});
|
|
110725
|
+
return { content: [{
|
|
110726
|
+
type: "text",
|
|
110727
|
+
text
|
|
110728
|
+
}] };
|
|
110729
|
+
}
|
|
110730
|
+
}];
|
|
110731
|
+
};
|
|
110732
|
+
/**
|
|
110733
|
+
* Reads the alert resources (`zendesk-sla://alerts/...`) with the caller's
|
|
110734
|
+
* token and thresholds, rendered like list_sla_alerts with its defaults. The
|
|
110735
|
+
* poller asks it for the fingerprint only. A view title resolves once per
|
|
110736
|
+
* reader (that is, per connection, session or grant): resolving pages through
|
|
110737
|
+
* /views, which no scope cache would spare on every poll.
|
|
110738
|
+
*/
|
|
110739
|
+
const createAlertsReader = (ctx) => {
|
|
110740
|
+
const viewIds = /* @__PURE__ */ new Map();
|
|
110741
|
+
const viewScope = async (view, token) => {
|
|
110742
|
+
const known = viewIds.get(view);
|
|
110743
|
+
if (known !== void 0) return {
|
|
110744
|
+
kind: "view",
|
|
110745
|
+
viewId: known
|
|
110746
|
+
};
|
|
110747
|
+
const resolved = await resolveViewId(ctx.subdomain, token, view);
|
|
110748
|
+
if ("available" in resolved) throw new Error(`No active view matches "${view}". Available views: ${resolved.available.join(", ") || "none"}.`);
|
|
110749
|
+
viewIds.set(view, resolved.id);
|
|
110750
|
+
return {
|
|
110751
|
+
kind: "view",
|
|
110752
|
+
viewId: resolved.id
|
|
110753
|
+
};
|
|
110754
|
+
};
|
|
110755
|
+
const evaluate = async (uri) => {
|
|
110756
|
+
const sla = requireSla(ctx.sla);
|
|
110757
|
+
const target = parseAlertsUri(uri);
|
|
110758
|
+
if (!target) throw new Error(`Not an SLA alert resource: ${uri}`);
|
|
110759
|
+
const token = await ctx.getToken();
|
|
110760
|
+
const scope = target.kind === "view" ? await viewScope(target.view, token) : target;
|
|
110761
|
+
const prefs = sla.userPreferencesAllowed ? await sla.preferences().catch(() => void 0) : void 0;
|
|
110762
|
+
try {
|
|
110763
|
+
return await checkAlerts({
|
|
110764
|
+
sla,
|
|
110765
|
+
get: (path, query) => zendeskGet(ctx.subdomain, token, path, query),
|
|
110766
|
+
target: scope,
|
|
110767
|
+
thresholds: await sla.thresholds(),
|
|
110768
|
+
source: thresholdsSource(sla, prefs, false),
|
|
110769
|
+
filter: { includeBreached: true },
|
|
110770
|
+
notes: []
|
|
110771
|
+
});
|
|
110772
|
+
} catch (err) {
|
|
110773
|
+
if (target.kind === "view") viewIds.delete(target.view);
|
|
110774
|
+
throw err;
|
|
110775
|
+
}
|
|
110776
|
+
};
|
|
110777
|
+
return {
|
|
110778
|
+
read: async (uri) => (await evaluate(uri)).text,
|
|
110779
|
+
fingerprint: async (uri) => (await evaluate(uri)).fingerprint
|
|
110780
|
+
};
|
|
110781
|
+
};
|
|
110782
|
+
//#endregion
|
|
109651
110783
|
//#region src/tools/users.ts
|
|
109652
110784
|
const createUserTools = (ctx) => {
|
|
109653
110785
|
const { subdomain, getToken } = ctx;
|
|
@@ -109783,10 +110915,12 @@ const createUserTools = (ctx) => {
|
|
|
109783
110915
|
//#region src/tools/index.ts
|
|
109784
110916
|
const createAllTools = (ctx) => [
|
|
109785
110917
|
...createTicketTools(ctx),
|
|
110918
|
+
...createSlaAlertTools(ctx),
|
|
109786
110919
|
...createRequestTools(ctx),
|
|
109787
110920
|
...createSearchTools(ctx),
|
|
109788
110921
|
...createHelpCenterTools(ctx),
|
|
109789
|
-
...createUserTools(ctx)
|
|
110922
|
+
...createUserTools(ctx),
|
|
110923
|
+
...createPreferenceTools(ctx)
|
|
109790
110924
|
];
|
|
109791
110925
|
//#endregion
|
|
109792
110926
|
//#region src/utils/logger.ts
|
|
@@ -109911,7 +111045,359 @@ const createRequestStateCodec = ({ key = randomBytes(32), principal = () => "" }
|
|
|
109911
111045
|
bind: (ctx) => `${ctx.mcpReq.method}\0${principal()}`
|
|
109912
111046
|
});
|
|
109913
111047
|
//#endregion
|
|
111048
|
+
//#region src/utils/sign-in-required.ts
|
|
111049
|
+
const SIGN_IN_REQUIRED = "SignInRequiredError";
|
|
111050
|
+
/**
|
|
111051
|
+
* What a background caller (the SLA alert poller) gets instead of a browser
|
|
111052
|
+
* sign-in: only a user action may open one, so the next tool call does.
|
|
111053
|
+
*/
|
|
111054
|
+
const signInRequiredError = () => Object.assign(/* @__PURE__ */ new Error("Not signed in to Zendesk: the next tool call starts the sign-in."), { name: SIGN_IN_REQUIRED });
|
|
111055
|
+
const isSignInRequired = (error) => error instanceof Error && error.name === SIGN_IN_REQUIRED;
|
|
111056
|
+
//#endregion
|
|
111057
|
+
//#region src/utils/sla-poller.ts
|
|
111058
|
+
/**
|
|
111059
|
+
* Polls the subscribed alert resources while there is at least one, and
|
|
111060
|
+
* notifies a URI only when its alert set changed: which tickets, metrics and
|
|
111061
|
+
* levels. The fingerprint at subscription time is the baseline, so the first
|
|
111062
|
+
* round never notifies on its own. The timer is unref'd and stops with the
|
|
111063
|
+
* last subscription.
|
|
111064
|
+
*/
|
|
111065
|
+
const createAlertPoller = (options) => {
|
|
111066
|
+
const { intervalMs, check, notify, onError = () => void 0 } = options;
|
|
111067
|
+
const fingerprints = /* @__PURE__ */ new Map();
|
|
111068
|
+
let timer;
|
|
111069
|
+
let running = false;
|
|
111070
|
+
const refresh = async (uri) => {
|
|
111071
|
+
try {
|
|
111072
|
+
const next = await check(uri);
|
|
111073
|
+
const previous = fingerprints.get(uri);
|
|
111074
|
+
if (previous !== void 0 && previous !== next) await notify(uri);
|
|
111075
|
+
if (fingerprints.has(uri)) fingerprints.set(uri, next);
|
|
111076
|
+
} catch (error) {
|
|
111077
|
+
onError(uri, error);
|
|
111078
|
+
}
|
|
111079
|
+
};
|
|
111080
|
+
const tick = async () => {
|
|
111081
|
+
if (running) return;
|
|
111082
|
+
running = true;
|
|
111083
|
+
try {
|
|
111084
|
+
for (const uri of [...fingerprints.keys()]) await refresh(uri);
|
|
111085
|
+
} finally {
|
|
111086
|
+
running = false;
|
|
111087
|
+
}
|
|
111088
|
+
};
|
|
111089
|
+
const stopTimer = () => {
|
|
111090
|
+
clearInterval(timer);
|
|
111091
|
+
timer = void 0;
|
|
111092
|
+
};
|
|
111093
|
+
return {
|
|
111094
|
+
subscribe: (uri) => {
|
|
111095
|
+
if (fingerprints.has(uri)) return;
|
|
111096
|
+
fingerprints.set(uri, void 0);
|
|
111097
|
+
refresh(uri);
|
|
111098
|
+
if (!timer) {
|
|
111099
|
+
timer = setInterval(() => void tick(), intervalMs);
|
|
111100
|
+
timer.unref();
|
|
111101
|
+
}
|
|
111102
|
+
},
|
|
111103
|
+
unsubscribe: (uri) => {
|
|
111104
|
+
fingerprints.delete(uri);
|
|
111105
|
+
if (fingerprints.size === 0) stopTimer();
|
|
111106
|
+
},
|
|
111107
|
+
stop: () => {
|
|
111108
|
+
fingerprints.clear();
|
|
111109
|
+
stopTimer();
|
|
111110
|
+
},
|
|
111111
|
+
subscriptions: () => [...fingerprints.keys()],
|
|
111112
|
+
tick
|
|
111113
|
+
};
|
|
111114
|
+
};
|
|
111115
|
+
//#endregion
|
|
111116
|
+
//#region src/auth/token-persistence.ts
|
|
111117
|
+
const isWindows = process.platform === "win32";
|
|
111118
|
+
/**
|
|
111119
|
+
* Config-dir segments derived from the *scoped* package name
|
|
111120
|
+
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
111121
|
+
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
111122
|
+
*/
|
|
111123
|
+
const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)/;
|
|
111124
|
+
const appDirSegments = () => {
|
|
111125
|
+
const { name } = readPackageInfo();
|
|
111126
|
+
const scoped = SCOPED_PACKAGE_NAME.exec(name);
|
|
111127
|
+
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
111128
|
+
};
|
|
111129
|
+
const configDir = () => {
|
|
111130
|
+
const segments = appDirSegments();
|
|
111131
|
+
if (isWindows) {
|
|
111132
|
+
const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
|
|
111133
|
+
return join(base, ...segments);
|
|
111134
|
+
}
|
|
111135
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
111136
|
+
return join(base, ...segments);
|
|
111137
|
+
};
|
|
111138
|
+
const safeName = (part) => part.replace(/[^a-z0-9-]/gi, "_");
|
|
111139
|
+
const READABLE_BUDGET = 120;
|
|
111140
|
+
const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
|
|
111141
|
+
key.subdomain,
|
|
111142
|
+
key.oauthClientId,
|
|
111143
|
+
key.scope
|
|
111144
|
+
])).digest("hex").slice(0, 8);
|
|
111145
|
+
/**
|
|
111146
|
+
* Path to the token file for one credential. Keyed on the whole `TokenKey`, so
|
|
111147
|
+
* a read-only server, a read-write one and one on another OAuth client each get
|
|
111148
|
+
* their own record instead of clobbering a shared file. Why that triple, and
|
|
111149
|
+
* why no migration from the old subdomain-only layout:
|
|
111150
|
+
* `docs/decisions/token-file-keying.md`.
|
|
111151
|
+
* `OAUTH_TOKEN_FILE` overrides with an explicit path — the way to separate
|
|
111152
|
+
* two Zendesk accounts that share a subdomain, client and scope.
|
|
111153
|
+
*/
|
|
111154
|
+
const resolveTokenPath = (key) => {
|
|
111155
|
+
const { name, value: override } = readEnv("OAUTH_TOKEN_FILE");
|
|
111156
|
+
if (override === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
|
|
111157
|
+
if (override) return override;
|
|
111158
|
+
const readable = [
|
|
111159
|
+
key.subdomain,
|
|
111160
|
+
key.oauthClientId,
|
|
111161
|
+
key.scope
|
|
111162
|
+
].map(safeName).join("--").slice(0, READABLE_BUDGET);
|
|
111163
|
+
return join(configDir(), `${readable}--${keyDigest(key)}.json`);
|
|
111164
|
+
};
|
|
111165
|
+
const writeFileAtomic = (path, record) => {
|
|
111166
|
+
const dir = dirname(path);
|
|
111167
|
+
mkdirSync(dir, { recursive: true });
|
|
111168
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
111169
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
|
|
111170
|
+
if (!isWindows) chmodSync(tmp, 384);
|
|
111171
|
+
renameSync(tmp, path);
|
|
111172
|
+
if (!isWindows) try {
|
|
111173
|
+
chmodSync(dir, 448);
|
|
111174
|
+
} catch {}
|
|
111175
|
+
};
|
|
111176
|
+
const loadToken = (path) => {
|
|
111177
|
+
try {
|
|
111178
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
111179
|
+
if (typeof raw?.accessToken === "string") return raw;
|
|
111180
|
+
} catch {}
|
|
111181
|
+
};
|
|
111182
|
+
const saveToken = (path, record, logger = silentLogger) => {
|
|
111183
|
+
try {
|
|
111184
|
+
writeFileAtomic(path, record);
|
|
111185
|
+
logger.debug("token_persisted");
|
|
111186
|
+
} catch (err) {
|
|
111187
|
+
logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
111188
|
+
}
|
|
111189
|
+
};
|
|
111190
|
+
const clearToken = (path, logger = silentLogger) => {
|
|
111191
|
+
try {
|
|
111192
|
+
rmSync(path, { force: true });
|
|
111193
|
+
logger.debug("token_cleared");
|
|
111194
|
+
} catch (err) {
|
|
111195
|
+
logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
111196
|
+
}
|
|
111197
|
+
};
|
|
111198
|
+
//#endregion
|
|
111199
|
+
//#region src/utils/scope-cache.ts
|
|
111200
|
+
/**
|
|
111201
|
+
* Serves `load()` from `store` for `ttlMs`, and makes concurrent calls for the
|
|
111202
|
+
* same key share one in-flight load. Zendesk charges a conditional GET as a
|
|
111203
|
+
* full request (PR #387 spike 2), so this lifetime is the only quota saving.
|
|
111204
|
+
* A store that fails to save only costs the cache, never the answer.
|
|
111205
|
+
*/
|
|
111206
|
+
const createScopeCache = (store, ttlMs) => {
|
|
111207
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
111208
|
+
return (key, load) => {
|
|
111209
|
+
const pending = inFlight.get(key);
|
|
111210
|
+
if (pending) return pending;
|
|
111211
|
+
const run = (async () => {
|
|
111212
|
+
const hit = await store.get(key).catch(() => void 0);
|
|
111213
|
+
if (hit !== void 0) return hit;
|
|
111214
|
+
const value = await load();
|
|
111215
|
+
await store.set(key, value, ttlMs).catch(() => void 0);
|
|
111216
|
+
return value;
|
|
111217
|
+
})().finally(() => inFlight.delete(key));
|
|
111218
|
+
inFlight.set(key, run);
|
|
111219
|
+
return run;
|
|
111220
|
+
};
|
|
111221
|
+
};
|
|
111222
|
+
//#endregion
|
|
111223
|
+
//#region src/utils/state-store.ts
|
|
111224
|
+
const isLive = (entry, now) => entry.expiresAt === void 0 || entry.expiresAt > now;
|
|
111225
|
+
const toEntry = (key, value, now, ttlMs) => ttlMs === void 0 ? {
|
|
111226
|
+
key,
|
|
111227
|
+
value
|
|
111228
|
+
} : {
|
|
111229
|
+
key,
|
|
111230
|
+
value,
|
|
111231
|
+
expiresAt: now + ttlMs
|
|
111232
|
+
};
|
|
111233
|
+
/** One process, nothing on disk: the default on HTTP, where sessions share the process. */
|
|
111234
|
+
const createMemoryStore = (now = Date.now) => {
|
|
111235
|
+
const entries = /* @__PURE__ */ new Map();
|
|
111236
|
+
return {
|
|
111237
|
+
get: async (key) => {
|
|
111238
|
+
const entry = entries.get(key);
|
|
111239
|
+
return entry && isLive(entry, now()) ? entry.value : void 0;
|
|
111240
|
+
},
|
|
111241
|
+
set: async (key, value, ttlMs) => {
|
|
111242
|
+
entries.set(key, toEntry(key, value, now(), ttlMs));
|
|
111243
|
+
},
|
|
111244
|
+
delete: async (key) => {
|
|
111245
|
+
entries.delete(key);
|
|
111246
|
+
},
|
|
111247
|
+
sweep: async () => {
|
|
111248
|
+
const at = now();
|
|
111249
|
+
for (const [key, entry] of entries) if (!isLive(entry, at)) entries.delete(key);
|
|
111250
|
+
return true;
|
|
111251
|
+
}
|
|
111252
|
+
};
|
|
111253
|
+
};
|
|
111254
|
+
const LOCK_FILE = "cleanup.lock";
|
|
111255
|
+
const errnoOf = (err) => err.code;
|
|
111256
|
+
const ignore = () => void 0;
|
|
111257
|
+
const remove = (path) => unlink(path).catch(ignore);
|
|
111258
|
+
const fileOf = (dir, key) => join(dir, `${createHash("sha256").update(key).digest("hex").slice(0, 32)}.json`);
|
|
111259
|
+
const readEntry = (path) => readFile(path, "utf8").then((text) => JSON.parse(text)).catch((err) => {
|
|
111260
|
+
if (err instanceof SyntaxError || errnoOf(err) === "ENOENT") return void 0;
|
|
111261
|
+
throw err;
|
|
111262
|
+
});
|
|
111263
|
+
/**
|
|
111264
|
+
* Entries as one file each under `dir` (0600, directory 0700), written to a
|
|
111265
|
+
* temp file then renamed so a reader never sees half an entry. Several stdio
|
|
111266
|
+
* processes we do not control share it; see `sweep` for how they clean up
|
|
111267
|
+
* without stepping on each other.
|
|
111268
|
+
*/
|
|
111269
|
+
const createDiskStore = (dir, options = {}) => {
|
|
111270
|
+
const { now = Date.now, lockStaleMs = 12e4, tempStaleMs = 3e5 } = options;
|
|
111271
|
+
const ensureDir = async () => {
|
|
111272
|
+
await mkdir(dir, {
|
|
111273
|
+
recursive: true,
|
|
111274
|
+
mode: 448
|
|
111275
|
+
});
|
|
111276
|
+
await chmod(dir, 448);
|
|
111277
|
+
};
|
|
111278
|
+
const acquireLock = async (lock, mayTakeOver = true) => {
|
|
111279
|
+
try {
|
|
111280
|
+
const handle = await open(lock, "wx", 384);
|
|
111281
|
+
await handle.writeFile(JSON.stringify({
|
|
111282
|
+
pid: process.pid,
|
|
111283
|
+
at: now()
|
|
111284
|
+
}));
|
|
111285
|
+
await handle.close();
|
|
111286
|
+
return true;
|
|
111287
|
+
} catch (err) {
|
|
111288
|
+
const code = errnoOf(err);
|
|
111289
|
+
if (code === "ENOENT") return null;
|
|
111290
|
+
if (code !== "EEXIST" || !mayTakeOver) return false;
|
|
111291
|
+
}
|
|
111292
|
+
const held = await readEntry(lock).catch(ignore);
|
|
111293
|
+
if (typeof held?.at === "number" && now() - held.at < lockStaleMs) return false;
|
|
111294
|
+
await remove(lock);
|
|
111295
|
+
return acquireLock(lock, false);
|
|
111296
|
+
};
|
|
111297
|
+
const sweepFile = async (name, at) => {
|
|
111298
|
+
const path = join(dir, name);
|
|
111299
|
+
if (name.endsWith(".tmp")) {
|
|
111300
|
+
const info = await stat(path).catch(ignore);
|
|
111301
|
+
if (info && at - info.mtimeMs > tempStaleMs) await remove(path);
|
|
111302
|
+
return;
|
|
111303
|
+
}
|
|
111304
|
+
const entry = await readEntry(path).catch(ignore);
|
|
111305
|
+
if (entry && !isLive(entry, at)) await remove(path);
|
|
111306
|
+
};
|
|
111307
|
+
return {
|
|
111308
|
+
get: async (key) => {
|
|
111309
|
+
const entry = await readEntry(fileOf(dir, key));
|
|
111310
|
+
return entry?.key === key && isLive(entry, now()) ? entry.value : void 0;
|
|
111311
|
+
},
|
|
111312
|
+
set: async (key, value, ttlMs) => {
|
|
111313
|
+
await ensureDir();
|
|
111314
|
+
const path = fileOf(dir, key);
|
|
111315
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
111316
|
+
await writeFile(temp, JSON.stringify(toEntry(key, value, now(), ttlMs)), { mode: 384 });
|
|
111317
|
+
await rename(temp, path);
|
|
111318
|
+
},
|
|
111319
|
+
delete: (key) => remove(fileOf(dir, key)),
|
|
111320
|
+
sweep: async () => {
|
|
111321
|
+
const lock = join(dir, LOCK_FILE);
|
|
111322
|
+
const acquired = await acquireLock(lock);
|
|
111323
|
+
if (acquired !== true) return acquired === null;
|
|
111324
|
+
try {
|
|
111325
|
+
const at = now();
|
|
111326
|
+
for (const name of await readdir(dir)) await sweepFile(name, at);
|
|
111327
|
+
} finally {
|
|
111328
|
+
await remove(lock);
|
|
111329
|
+
}
|
|
111330
|
+
return true;
|
|
111331
|
+
}
|
|
111332
|
+
};
|
|
111333
|
+
};
|
|
111334
|
+
/**
|
|
111335
|
+
* Sweeps `store` every `intervalMs`, never at startup. The timer is unref'd so
|
|
111336
|
+
* it never keeps a process alive; the returned function stops it.
|
|
111337
|
+
*/
|
|
111338
|
+
const startSweeper = (store, intervalMs = 6e5) => {
|
|
111339
|
+
const timer = setInterval(() => {
|
|
111340
|
+
store.sweep().catch(() => void 0);
|
|
111341
|
+
}, intervalMs);
|
|
111342
|
+
timer.unref();
|
|
111343
|
+
return () => clearInterval(timer);
|
|
111344
|
+
};
|
|
111345
|
+
//#endregion
|
|
111346
|
+
//#region src/utils/sla-service.ts
|
|
111347
|
+
const createSlaRuntime = (settings, store = createMemoryStore()) => ({
|
|
111348
|
+
store,
|
|
111349
|
+
cache: createScopeCache(store, settings.slaCacheTtlSeconds * 1e3)
|
|
111350
|
+
});
|
|
111351
|
+
/**
|
|
111352
|
+
* The runtime the entry point builds once per process: the store the config
|
|
111353
|
+
* selects, next to the token files when on disk, swept in the background.
|
|
111354
|
+
* `stop` ends the sweeper.
|
|
111355
|
+
*/
|
|
111356
|
+
const startSlaRuntime = (config) => {
|
|
111357
|
+
const store = slaStateStoreOf(config) === "disk" ? createDiskStore(join(configDir(), "sla-state")) : createMemoryStore();
|
|
111358
|
+
return {
|
|
111359
|
+
runtime: createSlaRuntime(config, store),
|
|
111360
|
+
stop: startSweeper(store)
|
|
111361
|
+
};
|
|
111362
|
+
};
|
|
111363
|
+
const MAX_REMEMBERED_TOKENS = 8;
|
|
111364
|
+
/**
|
|
111365
|
+
* Resolves the caller's Zendesk user id once per token (`/users/me`), so a
|
|
111366
|
+
* refreshed or another user's token can never read an entry cached under the
|
|
111367
|
+
* previous one. A failed lookup is forgotten, to be retried on the next call.
|
|
111368
|
+
*/
|
|
111369
|
+
const createUserIdResolver = (subdomain, getToken) => {
|
|
111370
|
+
const byToken = /* @__PURE__ */ new Map();
|
|
111371
|
+
return async () => {
|
|
111372
|
+
const token = await getToken();
|
|
111373
|
+
const known = byToken.get(token);
|
|
111374
|
+
if (known) return known;
|
|
111375
|
+
const lookup = zendeskGet(subdomain, token, "/users/me").then(({ user }) => user.id);
|
|
111376
|
+
lookup.catch(() => byToken.delete(token));
|
|
111377
|
+
if (byToken.size >= MAX_REMEMBERED_TOKENS) byToken.delete(byToken.keys().next().value);
|
|
111378
|
+
byToken.set(token, lookup);
|
|
111379
|
+
return lookup;
|
|
111380
|
+
};
|
|
111381
|
+
};
|
|
111382
|
+
const createSlaService = (settings, runtime, userId) => {
|
|
111383
|
+
const userKey = async () => `${settings.subdomain}:${await userId()}`;
|
|
111384
|
+
const preferences = async () => await runtime.store.get(`prefs:${await userKey()}`);
|
|
111385
|
+
return {
|
|
111386
|
+
push: settings.slaPush,
|
|
111387
|
+
userPreferencesAllowed: settings.slaUserPreferences,
|
|
111388
|
+
adminThresholds: settings.slaThresholds,
|
|
111389
|
+
thresholds: async () => settings.slaUserPreferences ? preferences().then((prefs) => effectiveThresholds(settings.slaThresholds, prefs), () => settings.slaThresholds) : settings.slaThresholds,
|
|
111390
|
+
preferences,
|
|
111391
|
+
savePreferences: async (prefs) => runtime.store.set(`prefs:${await userKey()}`, prefs),
|
|
111392
|
+
userId,
|
|
111393
|
+
cached: async (scope, load) => runtime.cache(`cache:${await userKey()}:${scope}`, load)
|
|
111394
|
+
};
|
|
111395
|
+
};
|
|
111396
|
+
//#endregion
|
|
109914
111397
|
//#region src/server.ts
|
|
111398
|
+
const noteUnauthorized = (err, onUnauthorized) => {
|
|
111399
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
111400
|
+
};
|
|
109915
111401
|
/**
|
|
109916
111402
|
* Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
|
|
109917
111403
|
* token (401), so the OAuth store drops the dead token instead of replaying it.
|
|
@@ -109924,19 +111410,25 @@ const createRequestStateCodec = ({ key = randomBytes(32), principal = () => "" }
|
|
|
109924
111410
|
* this rare — it fires only for a token revoked between the pre-call check and
|
|
109925
111411
|
* the request.
|
|
109926
111412
|
*/
|
|
109927
|
-
const runHandler = async (def, params, onUnauthorized) => {
|
|
111413
|
+
const runHandler = async (def, params, onUnauthorized, call) => {
|
|
109928
111414
|
try {
|
|
109929
|
-
return await def.handler(params);
|
|
111415
|
+
return await def.handler(params, call);
|
|
109930
111416
|
} catch (err) {
|
|
109931
|
-
if (
|
|
111417
|
+
if (isInputRequiredError(err)) return err.inputRequired;
|
|
111418
|
+
noteUnauthorized(err, onUnauthorized);
|
|
109932
111419
|
throw err;
|
|
109933
111420
|
}
|
|
109934
111421
|
};
|
|
111422
|
+
/** The parts of the SDK request context a tool handler may use. */
|
|
111423
|
+
const toCallContext = (server, ctx) => ({
|
|
111424
|
+
canElicit: supportsFormElicitation(ctx.mcpReq.envelope?.["io.modelcontextprotocol/clientCapabilities"] ?? server.server.getClientCapabilities()),
|
|
111425
|
+
inputResponses: ctx.mcpReq.inputResponses
|
|
111426
|
+
});
|
|
109935
111427
|
const aggregateAnnotations = (tools) => ({
|
|
109936
111428
|
readOnlyHint: tools.every((t) => t.annotations.readOnlyHint),
|
|
109937
111429
|
destructiveHint: tools.some((t) => t.annotations.destructiveHint),
|
|
109938
111430
|
idempotentHint: tools.every((t) => t.annotations.idempotentHint),
|
|
109939
|
-
openWorldHint:
|
|
111431
|
+
openWorldHint: tools.some((t) => t.annotations.openWorldHint)
|
|
109940
111432
|
});
|
|
109941
111433
|
const buildProxyDispatch = (tools, onUnauthorized) => {
|
|
109942
111434
|
const operationNames = tools.map((t) => t.name);
|
|
@@ -109944,7 +111436,7 @@ const buildProxyDispatch = (tools, onUnauthorized) => {
|
|
|
109944
111436
|
def: t,
|
|
109945
111437
|
parseParams: createStrictParamsParser(t.inputSchema)
|
|
109946
111438
|
}]));
|
|
109947
|
-
return async (args) => {
|
|
111439
|
+
return async (args, call) => {
|
|
109948
111440
|
const { operation, params } = args;
|
|
109949
111441
|
const entry = localHandlers.get(operation);
|
|
109950
111442
|
if (!entry) return { content: [{
|
|
@@ -109952,7 +111444,7 @@ const buildProxyDispatch = (tools, onUnauthorized) => {
|
|
|
109952
111444
|
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
109953
111445
|
}] };
|
|
109954
111446
|
const validated = entry.parseParams(params);
|
|
109955
|
-
return runHandler(entry.def, validated, onUnauthorized);
|
|
111447
|
+
return runHandler(entry.def, validated, onUnauthorized, call);
|
|
109956
111448
|
};
|
|
109957
111449
|
};
|
|
109958
111450
|
const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
|
|
@@ -109963,14 +111455,25 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
109963
111455
|
description: buildProxyDescription({
|
|
109964
111456
|
title,
|
|
109965
111457
|
tools,
|
|
109966
|
-
readOnly: readOnlyMode
|
|
111458
|
+
readOnly: readOnlyMode && annotations.readOnlyHint
|
|
109967
111459
|
}),
|
|
109968
111460
|
inputSchema: object({
|
|
109969
111461
|
operation: string$2().describe(buildOperationFieldDescription(tools)),
|
|
109970
111462
|
params: record(string$2(), unknown$3()).default({}).describe(PARAMS_FIELD_DESCRIPTION)
|
|
109971
111463
|
}),
|
|
109972
111464
|
annotations
|
|
109973
|
-
}, async (args) => dispatch(args));
|
|
111465
|
+
}, async (args, ctx) => dispatch(args, toCallContext(server, ctx)));
|
|
111466
|
+
};
|
|
111467
|
+
/**
|
|
111468
|
+
* `single` mode: one `zendesk` proxy for every Zendesk tool. The preferences
|
|
111469
|
+
* configure this server, not Zendesk: they keep their own proxy, so `zendesk`
|
|
111470
|
+
* stays `[RO]` and readOnlyHint under --read-only.
|
|
111471
|
+
*/
|
|
111472
|
+
const registerSingleProxies = (server, tools, readOnlyMode, onUnauthorized) => {
|
|
111473
|
+
const preferences = tools.filter((tool) => tool.namespace === "preferences");
|
|
111474
|
+
const zendesk = tools.filter((tool) => tool.namespace !== "preferences");
|
|
111475
|
+
const label = NAMESPACE_LABELS.preferences;
|
|
111476
|
+
return [...zendesk.length > 0 || preferences.length === 0 ? [registerProxyTool(server, "zendesk", "Zendesk", zendesk, readOnlyMode, onUnauthorized)] : [], ...preferences.length > 0 ? [registerProxyTool(server, label.toolName, label.title, preferences, readOnlyMode, onUnauthorized)] : []];
|
|
109974
111477
|
};
|
|
109975
111478
|
/**
|
|
109976
111479
|
* Build the bare `McpServer` — identity, `instructions` — with no tools or
|
|
@@ -109993,10 +111496,158 @@ const createServerCaches = (config, getToken, onUnauthorized) => {
|
|
|
109993
111496
|
const resolveBrandSubdomain = createBrandSubdomainResolver(config.subdomain, getToken);
|
|
109994
111497
|
return {
|
|
109995
111498
|
resolveBrandSubdomain,
|
|
111499
|
+
resolveUserId: createUserIdResolver(config.subdomain, getToken),
|
|
109996
111500
|
topology: helpCenterContextEnabled(config) ? createTopologyProvider(getToken, config.subdomain, onUnauthorized, config.brandIds, firstBrandSubdomainResolver(config, resolveBrandSubdomain)) : void 0,
|
|
109997
111501
|
articles: articleResourceEnabled(config) ? createArticleResourcesProvider(getToken, config.subdomain, onUnauthorized, resourceBrandResolver(config, resolveBrandSubdomain), config.brandIds) : void 0
|
|
109998
111502
|
};
|
|
109999
111503
|
};
|
|
111504
|
+
const ALERTS_MIME = "text/markdown";
|
|
111505
|
+
const registerAlertResources = (server, reader, push) => {
|
|
111506
|
+
const read = async (uri) => ({ contents: [{
|
|
111507
|
+
uri: uri.toString(),
|
|
111508
|
+
mimeType: ALERTS_MIME,
|
|
111509
|
+
text: await reader.read(uri.toString())
|
|
111510
|
+
}] });
|
|
111511
|
+
const describe = `SLA metrics breached or about to breach, one line each, grouped BREACHED / CRITICAL / WARNING and soonest first, against your alert thresholds; the same report as list_sla_alerts.${push ? " Subscribe to be told when the set of alerts changes." : ""}`;
|
|
111512
|
+
return [
|
|
111513
|
+
server.registerResource("sla-alerts", new ResourceTemplate(SLA_ALERTS_TEMPLATES.named, { list: async () => ({ resources: NAMED_SCOPES.map((scope) => ({
|
|
111514
|
+
uri: namedAlertsUri(scope),
|
|
111515
|
+
name: `SLA alerts: ${scope}`,
|
|
111516
|
+
mimeType: ALERTS_MIME
|
|
111517
|
+
})) }) }), {
|
|
111518
|
+
title: "Zendesk SLA alerts",
|
|
111519
|
+
description: `${describe} {scope} is mine, my_groups or all (unsolved tickets).`,
|
|
111520
|
+
mimeType: ALERTS_MIME
|
|
111521
|
+
}, read),
|
|
111522
|
+
server.registerResource("sla-alerts-view", new ResourceTemplate(SLA_ALERTS_TEMPLATES.view, { list: void 0 }), {
|
|
111523
|
+
title: "Zendesk SLA alerts in a view",
|
|
111524
|
+
description: `${describe} {view} is a view title or id, as list_views shows it.`,
|
|
111525
|
+
mimeType: ALERTS_MIME
|
|
111526
|
+
}, read),
|
|
111527
|
+
server.registerResource("sla-alerts-query", new ResourceTemplate(SLA_ALERTS_TEMPLATES.query, { list: void 0 }), {
|
|
111528
|
+
title: "Zendesk SLA alerts for a search",
|
|
111529
|
+
description: `${describe} {query} is any Zendesk ticket search query.`,
|
|
111530
|
+
mimeType: ALERTS_MIME
|
|
111531
|
+
}, read)
|
|
111532
|
+
];
|
|
111533
|
+
};
|
|
111534
|
+
/** Re-checks each subscribed URI every `slaPollSeconds`; notifies when its alert set changes. */
|
|
111535
|
+
const createAlertsPoller = (config, reader, notify, logger) => createAlertPoller({
|
|
111536
|
+
intervalMs: config.slaPollSeconds * 1e3,
|
|
111537
|
+
check: reader.fingerprint,
|
|
111538
|
+
notify,
|
|
111539
|
+
onError: (uri, error) => {
|
|
111540
|
+
if (isSignInRequired(error)) {
|
|
111541
|
+
logger.debug("sla_alerts_poll_skipped", { uri });
|
|
111542
|
+
return;
|
|
111543
|
+
}
|
|
111544
|
+
logger.warn("sla_alerts_poll_failed", {
|
|
111545
|
+
uri,
|
|
111546
|
+
error: error instanceof Error ? error.message : String(error)
|
|
111547
|
+
});
|
|
111548
|
+
}
|
|
111549
|
+
});
|
|
111550
|
+
/**
|
|
111551
|
+
* The SLA settings a server applies: personal thresholds only where
|
|
111552
|
+
* `configure_sla_alerts`, the one way to change them, is exposed.
|
|
111553
|
+
*/
|
|
111554
|
+
const slaSettings = (config) => ({
|
|
111555
|
+
...config,
|
|
111556
|
+
slaUserPreferences: slaPreferencesEnabled(config)
|
|
111557
|
+
});
|
|
111558
|
+
/** The context the tools and the alert readers share. */
|
|
111559
|
+
const toolContext = (config, getToken, resolveBrandSubdomain, sla) => ({
|
|
111560
|
+
subdomain: config.subdomain,
|
|
111561
|
+
brandIds: config.brandIds,
|
|
111562
|
+
resolveBrandSubdomain,
|
|
111563
|
+
getToken,
|
|
111564
|
+
sla
|
|
111565
|
+
});
|
|
111566
|
+
/** `reader`, with a Zendesk 401 reported like a tool's: the dead token is dropped, not replayed. */
|
|
111567
|
+
const reportingUnauthorized = (reader, onUnauthorized) => {
|
|
111568
|
+
const note = (err) => {
|
|
111569
|
+
noteUnauthorized(err, onUnauthorized);
|
|
111570
|
+
throw err;
|
|
111571
|
+
};
|
|
111572
|
+
return {
|
|
111573
|
+
read: (uri) => reader.read(uri).catch(note),
|
|
111574
|
+
fingerprint: (uri) => reader.fingerprint(uri).catch(note)
|
|
111575
|
+
};
|
|
111576
|
+
};
|
|
111577
|
+
/** The reader the SLA alert poller checks with. */
|
|
111578
|
+
const createAlertPollingReader = ({ config, getToken, resolveUserId, caches, slaRuntime, onUnauthorized }) => reportingUnauthorized(createAlertsReader(toolContext(config, getToken, caches.resolveBrandSubdomain, createSlaService(slaSettings(config), slaRuntime, resolveUserId))), onUnauthorized);
|
|
111579
|
+
/**
|
|
111580
|
+
* The token source and user-id lookup a poller runs with: a background one
|
|
111581
|
+
* when the transport has it (stdio, whose `getToken` may open a browser).
|
|
111582
|
+
*/
|
|
111583
|
+
const pollingIdentity = (config, getToken, caches, getBackgroundToken) => getBackgroundToken ? {
|
|
111584
|
+
getToken: getBackgroundToken,
|
|
111585
|
+
resolveUserId: createUserIdResolver(config.subdomain, getBackgroundToken)
|
|
111586
|
+
} : {
|
|
111587
|
+
getToken,
|
|
111588
|
+
resolveUserId: caches.resolveUserId
|
|
111589
|
+
};
|
|
111590
|
+
/** Polls the SLA alert resources a 2026-07-28 client listens to; other URIs are not ours. */
|
|
111591
|
+
const pollListenedAlerts = (poller, listens) => listens.attach({
|
|
111592
|
+
add: (uri) => {
|
|
111593
|
+
if (parseAlertsUri(uri)) poller.subscribe(uri);
|
|
111594
|
+
},
|
|
111595
|
+
remove: (uri) => poller.unsubscribe(uri)
|
|
111596
|
+
});
|
|
111597
|
+
/**
|
|
111598
|
+
* The server pushing alert changes on its own, through `resources/subscribe`
|
|
111599
|
+
* for a 2025 client and the `listens` streams for a 2026-07-28 one when the
|
|
111600
|
+
* server lives as long as its connection (stdio). Installed once per server,
|
|
111601
|
+
* before it connects, and only with push on. Stops with the server.
|
|
111602
|
+
*/
|
|
111603
|
+
const installAlertSubscriptions = (server, config, reader, logger = silentLogger, listens) => {
|
|
111604
|
+
const poller = createAlertsPoller(config, reader, (uri) => server.server.sendResourceUpdated({ uri }), logger);
|
|
111605
|
+
const subscribable = (uri) => {
|
|
111606
|
+
if (!parseAlertsUri(uri)) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Only SLA alert resources (${SLA_RESOURCE_SCHEME}://alerts/...) can be subscribed to: ${uri}`);
|
|
111607
|
+
return uri;
|
|
111608
|
+
};
|
|
111609
|
+
server.server.registerCapabilities({ resources: { subscribe: true } });
|
|
111610
|
+
server.server.setRequestHandler("resources/subscribe", (request) => {
|
|
111611
|
+
poller.subscribe(subscribable(request.params.uri));
|
|
111612
|
+
return {};
|
|
111613
|
+
});
|
|
111614
|
+
server.server.setRequestHandler("resources/unsubscribe", (request) => {
|
|
111615
|
+
poller.unsubscribe(request.params.uri);
|
|
111616
|
+
return {};
|
|
111617
|
+
});
|
|
111618
|
+
const detach = listens ? pollListenedAlerts(poller, listens) : void 0;
|
|
111619
|
+
const previous = server.server.onclose;
|
|
111620
|
+
server.server.onclose = () => {
|
|
111621
|
+
detach?.();
|
|
111622
|
+
poller.stop();
|
|
111623
|
+
previous?.();
|
|
111624
|
+
};
|
|
111625
|
+
return poller;
|
|
111626
|
+
};
|
|
111627
|
+
/**
|
|
111628
|
+
* The SLA alert polling of a 2026-07-28 HTTP grant: its servers last one
|
|
111629
|
+
* request each, so the poller lives with the grant, fed by the grant's listen
|
|
111630
|
+
* streams. Returns the stop; a no-op when the push is off.
|
|
111631
|
+
*/
|
|
111632
|
+
const watchGrantAlerts = ({ config, getToken, caches, slaRuntime, logger, listens, notify, onUnauthorized }) => {
|
|
111633
|
+
if (!(config.slaPush && slaAlertsEnabled(config))) return () => void 0;
|
|
111634
|
+
const reader = createAlertPollingReader({
|
|
111635
|
+
config,
|
|
111636
|
+
getToken,
|
|
111637
|
+
resolveUserId: caches.resolveUserId,
|
|
111638
|
+
caches,
|
|
111639
|
+
slaRuntime,
|
|
111640
|
+
onUnauthorized
|
|
111641
|
+
});
|
|
111642
|
+
const poller = createAlertsPoller(config, reader, async (uri) => {
|
|
111643
|
+
notify(uri);
|
|
111644
|
+
}, logger);
|
|
111645
|
+
const detach = pollListenedAlerts(poller, listens);
|
|
111646
|
+
return () => {
|
|
111647
|
+
detach();
|
|
111648
|
+
poller.stop();
|
|
111649
|
+
};
|
|
111650
|
+
};
|
|
110000
111651
|
/**
|
|
110001
111652
|
* Lazily resolve the Help Center subdomain the topology resource pins to: the
|
|
110002
111653
|
* FIRST allowed brand when a list is set (named in the header), else undefined
|
|
@@ -110032,8 +111683,9 @@ const resourceBrandResolver = (config, resolveBrandSubdomain) => {
|
|
|
110032
111683
|
* without dropping the transport. `tools` is passed in (not built here) so the
|
|
110033
111684
|
* reload path can hand over freshly re-imported definitions.
|
|
110034
111685
|
*/
|
|
110035
|
-
const registerToolset = (server, { config, getToken, caches, onUnauthorized, logger = silentLogger }, tools) => {
|
|
110036
|
-
const
|
|
111686
|
+
const registerToolset = (server, { config, getToken, caches, onUnauthorized, logger = silentLogger, sla }, tools) => {
|
|
111687
|
+
const shared = caches ?? createServerCaches(config, getToken, onUnauthorized);
|
|
111688
|
+
const { topology, articles } = shared;
|
|
110037
111689
|
const registered = [];
|
|
110038
111690
|
const dispose = () => {
|
|
110039
111691
|
for (const handle of registered) handle.remove();
|
|
@@ -110051,7 +111703,7 @@ const registerToolset = (server, { config, getToken, caches, onUnauthorized, log
|
|
|
110051
111703
|
description: tool.description,
|
|
110052
111704
|
inputSchema: toStrictStandardSchema(tool.inputSchema),
|
|
110053
111705
|
annotations: tool.annotations
|
|
110054
|
-
}, async (params) => runHandler(tool, params, onUnauthorized)));
|
|
111706
|
+
}, async (params, ctx) => runHandler(tool, params, onUnauthorized, toCallContext(server, ctx))));
|
|
110055
111707
|
break;
|
|
110056
111708
|
case "namespace": {
|
|
110057
111709
|
const grouped = groupByNamespace(filteredTools);
|
|
@@ -110062,7 +111714,7 @@ const registerToolset = (server, { config, getToken, caches, onUnauthorized, log
|
|
|
110062
111714
|
break;
|
|
110063
111715
|
}
|
|
110064
111716
|
case "single":
|
|
110065
|
-
registered.push(
|
|
111717
|
+
registered.push(...registerSingleProxies(server, filteredTools, config.readOnly, onUnauthorized));
|
|
110066
111718
|
break;
|
|
110067
111719
|
default: {
|
|
110068
111720
|
const unhandled = config.mode;
|
|
@@ -110117,6 +111769,7 @@ const registerToolset = (server, { config, getToken, caches, onUnauthorized, log
|
|
|
110117
111769
|
}] };
|
|
110118
111770
|
}));
|
|
110119
111771
|
}
|
|
111772
|
+
if (sla && slaAlertsEnabled(config)) registered.push(...registerAlertResources(server, reportingUnauthorized(createAlertsReader(toolContext(config, getToken, shared.resolveBrandSubdomain, sla)), onUnauthorized), config.slaPush));
|
|
110120
111773
|
} catch (err) {
|
|
110121
111774
|
dispose();
|
|
110122
111775
|
throw err;
|
|
@@ -110130,25 +111783,38 @@ const registerToolset = (server, { config, getToken, caches, onUnauthorized, log
|
|
|
110130
111783
|
dispose
|
|
110131
111784
|
};
|
|
110132
111785
|
};
|
|
110133
|
-
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized, { caches = createServerCaches(config, getToken, onUnauthorized), requestStateCodec } = {}) => {
|
|
111786
|
+
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized, { caches = createServerCaches(config, getToken, onUnauthorized), requestStateCodec, slaRuntime = createSlaRuntime(config), listens, getBackgroundToken, alertPolling = "server" } = {}) => {
|
|
110134
111787
|
const server = createServerShell(config, requestStateCodec);
|
|
110135
|
-
const
|
|
110136
|
-
subdomain: config.subdomain,
|
|
110137
|
-
brandIds: config.brandIds,
|
|
110138
|
-
resolveBrandSubdomain: caches.resolveBrandSubdomain,
|
|
110139
|
-
getToken
|
|
110140
|
-
});
|
|
111788
|
+
const sla = createSlaService(slaSettings(config), slaRuntime, caches.resolveUserId);
|
|
110141
111789
|
registerToolset(server, {
|
|
110142
111790
|
config,
|
|
110143
111791
|
getToken,
|
|
110144
111792
|
caches,
|
|
110145
111793
|
onUnauthorized,
|
|
110146
|
-
logger
|
|
110147
|
-
|
|
111794
|
+
logger,
|
|
111795
|
+
sla
|
|
111796
|
+
}, createAllTools(toolContext(config, getToken, caches.resolveBrandSubdomain, sla)));
|
|
111797
|
+
if (config.slaPush && slaAlertsEnabled(config)) {
|
|
111798
|
+
if (alertPolling === "grant") server.server.registerCapabilities({ resources: { subscribe: true } });
|
|
111799
|
+
else {
|
|
111800
|
+
const reader = createAlertPollingReader({
|
|
111801
|
+
config,
|
|
111802
|
+
...pollingIdentity(config, getToken, caches, getBackgroundToken),
|
|
111803
|
+
caches,
|
|
111804
|
+
slaRuntime,
|
|
111805
|
+
onUnauthorized
|
|
111806
|
+
});
|
|
111807
|
+
installAlertSubscriptions(server, config, reader, logger, listens);
|
|
111808
|
+
}
|
|
111809
|
+
}
|
|
110148
111810
|
return server;
|
|
110149
111811
|
};
|
|
110150
|
-
const defaultServerFactory = ({ config, getToken, logger, onUnauthorized, caches, requestStateCodec }) => createMcpServer(config, getToken, logger, onUnauthorized, {
|
|
111812
|
+
const defaultServerFactory = ({ config, getToken, logger, onUnauthorized, caches, requestStateCodec, slaRuntime, listens, getBackgroundToken, alertPolling }) => createMcpServer(config, getToken, logger, onUnauthorized, {
|
|
110151
111813
|
...caches ? { caches } : {},
|
|
111814
|
+
...slaRuntime ? { slaRuntime } : {},
|
|
111815
|
+
...listens ? { listens } : {},
|
|
111816
|
+
...getBackgroundToken ? { getBackgroundToken } : {},
|
|
111817
|
+
...alertPolling ? { alertPolling } : {},
|
|
110152
111818
|
requestStateCodec
|
|
110153
111819
|
});
|
|
110154
111820
|
//#endregion
|
|
@@ -110731,8 +112397,119 @@ function toError(value) {
|
|
|
110731
112397
|
return value instanceof Error ? value : new Error(String(value));
|
|
110732
112398
|
}
|
|
110733
112399
|
//#endregion
|
|
112400
|
+
//#region src/utils/listen-feed.ts
|
|
112401
|
+
const tell = (sink, change, uri) => {
|
|
112402
|
+
try {
|
|
112403
|
+
sink[change](uri);
|
|
112404
|
+
} catch {}
|
|
112405
|
+
};
|
|
112406
|
+
const createListenFeed = () => {
|
|
112407
|
+
const streams = /* @__PURE__ */ new Map();
|
|
112408
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
112409
|
+
const sinks = /* @__PURE__ */ new Set();
|
|
112410
|
+
const close = (key) => {
|
|
112411
|
+
const uris = streams.get(key);
|
|
112412
|
+
if (!uris) return;
|
|
112413
|
+
streams.delete(key);
|
|
112414
|
+
for (const uri of uris) {
|
|
112415
|
+
const count = (listeners.get(uri) ?? 0) - 1;
|
|
112416
|
+
if (count > 0) {
|
|
112417
|
+
listeners.set(uri, count);
|
|
112418
|
+
continue;
|
|
112419
|
+
}
|
|
112420
|
+
listeners.delete(uri);
|
|
112421
|
+
for (const sink of sinks) tell(sink, "remove", uri);
|
|
112422
|
+
}
|
|
112423
|
+
};
|
|
112424
|
+
return {
|
|
112425
|
+
open: (key, uris) => {
|
|
112426
|
+
if (streams.has(key)) return;
|
|
112427
|
+
const unique = [...new Set(uris)];
|
|
112428
|
+
streams.set(key, unique);
|
|
112429
|
+
for (const uri of unique) {
|
|
112430
|
+
const count = (listeners.get(uri) ?? 0) + 1;
|
|
112431
|
+
listeners.set(uri, count);
|
|
112432
|
+
if (count === 1) for (const sink of sinks) tell(sink, "add", uri);
|
|
112433
|
+
}
|
|
112434
|
+
},
|
|
112435
|
+
close,
|
|
112436
|
+
closeAll: () => {
|
|
112437
|
+
for (const key of [...streams.keys()]) close(key);
|
|
112438
|
+
},
|
|
112439
|
+
attach: (sink) => {
|
|
112440
|
+
sinks.add(sink);
|
|
112441
|
+
for (const uri of listeners.keys()) tell(sink, "add", uri);
|
|
112442
|
+
return () => {
|
|
112443
|
+
sinks.delete(sink);
|
|
112444
|
+
};
|
|
112445
|
+
}
|
|
112446
|
+
};
|
|
112447
|
+
};
|
|
112448
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
112449
|
+
/**
|
|
112450
|
+
* The resource URIs a `subscriptions/listen` request asks for, or `undefined`
|
|
112451
|
+
* when `message` is not one. A malformed filter reads as no URI: the SDK
|
|
112452
|
+
* refuses that request anyway.
|
|
112453
|
+
*/
|
|
112454
|
+
const listenedUris = (message) => {
|
|
112455
|
+
if (!isRecord(message) || message["method"] !== "subscriptions/listen") return void 0;
|
|
112456
|
+
const params = isRecord(message["params"]) ? message["params"] : {};
|
|
112457
|
+
const uris = (isRecord(params["notifications"]) ? params["notifications"] : {})["resourceSubscriptions"];
|
|
112458
|
+
return Array.isArray(uris) ? uris.filter((uri) => typeof uri === "string") : [];
|
|
112459
|
+
};
|
|
112460
|
+
/**
|
|
112461
|
+
* Feeds `feed` from a JSON-RPC connection's traffic: a listen request opens a
|
|
112462
|
+
* stream under its id; the client cancelling it, or any response carrying that
|
|
112463
|
+
* id (the SDK's refusal or its closing result), ends it.
|
|
112464
|
+
*/
|
|
112465
|
+
const createListenWireObserver = (feed) => ({
|
|
112466
|
+
inbound: (message) => {
|
|
112467
|
+
if (!isRecord(message)) return;
|
|
112468
|
+
const uris = listenedUris(message);
|
|
112469
|
+
if (uris && "id" in message) {
|
|
112470
|
+
feed.open(message["id"], uris);
|
|
112471
|
+
return;
|
|
112472
|
+
}
|
|
112473
|
+
if (message["method"] === "notifications/cancelled" && isRecord(message["params"])) feed.close(message["params"]["requestId"]);
|
|
112474
|
+
},
|
|
112475
|
+
outbound: (message) => {
|
|
112476
|
+
if (isRecord(message) && !("method" in message) && "id" in message) feed.close(message["id"]);
|
|
112477
|
+
}
|
|
112478
|
+
});
|
|
112479
|
+
//#endregion
|
|
110734
112480
|
//#region src/transports/stdio.ts
|
|
110735
112481
|
/**
|
|
112482
|
+
* `wire`, with its traffic reported to `feed`: `serveStdio` answers
|
|
112483
|
+
* `subscriptions/listen` before any server sees it.
|
|
112484
|
+
*/
|
|
112485
|
+
const observeListens = (wire, feed) => {
|
|
112486
|
+
const observer = createListenWireObserver(feed);
|
|
112487
|
+
return {
|
|
112488
|
+
start: () => wire.start(),
|
|
112489
|
+
send: (message, options) => {
|
|
112490
|
+
observer.outbound(message);
|
|
112491
|
+
return wire.send(message, options);
|
|
112492
|
+
},
|
|
112493
|
+
close: () => wire.close(),
|
|
112494
|
+
setProtocolVersion: (version) => wire.setProtocolVersion?.(version),
|
|
112495
|
+
set onmessage(handler) {
|
|
112496
|
+
wire.onmessage = handler && ((message, extra) => {
|
|
112497
|
+
observer.inbound(message);
|
|
112498
|
+
handler(message, extra);
|
|
112499
|
+
});
|
|
112500
|
+
},
|
|
112501
|
+
set onclose(handler) {
|
|
112502
|
+
wire.onclose = () => {
|
|
112503
|
+
feed.closeAll();
|
|
112504
|
+
handler?.();
|
|
112505
|
+
};
|
|
112506
|
+
},
|
|
112507
|
+
set onerror(handler) {
|
|
112508
|
+
wire.onerror = handler;
|
|
112509
|
+
}
|
|
112510
|
+
};
|
|
112511
|
+
};
|
|
112512
|
+
/**
|
|
110736
112513
|
* Serve MCP over stdio, both protocol eras: the SDK entry picks the era from the
|
|
110737
112514
|
* client's opening exchange and pins one server from `build` per connection (a
|
|
110738
112515
|
* `server/discover` probe that falls back to `initialize` builds one more, then
|
|
@@ -110740,17 +112517,19 @@ function toError(value) {
|
|
|
110740
112517
|
*/
|
|
110741
112518
|
const startStdioTransport = (build, logger = silentLogger, options = {}) => {
|
|
110742
112519
|
const requestStateCodec = createRequestStateCodec({});
|
|
112520
|
+
const listens = createListenFeed();
|
|
110743
112521
|
const handle = serveStdio(() => {
|
|
110744
112522
|
let server;
|
|
110745
112523
|
server = build({
|
|
110746
112524
|
requestStateCodec,
|
|
112525
|
+
listens,
|
|
110747
112526
|
notifyResourceUpdated: (uri) => {
|
|
110748
112527
|
server?.server.sendResourceUpdated({ uri }).catch(() => void 0);
|
|
110749
112528
|
}
|
|
110750
112529
|
});
|
|
110751
112530
|
return server;
|
|
110752
112531
|
}, {
|
|
110753
|
-
transport: options.transport ?? new StdioServerTransport(void 0, void 0, { maxBufferSize: 10485760 }),
|
|
112532
|
+
transport: observeListens(options.transport ?? new StdioServerTransport(void 0, void 0, { maxBufferSize: 10485760 }), listens),
|
|
110754
112533
|
onerror: (error) => logger.warn("stdio_transport_error", { error: error.message })
|
|
110755
112534
|
});
|
|
110756
112535
|
logger.info("stdio_transport_ready");
|
|
@@ -110765,6 +112544,10 @@ const TOOL_MODULES = [
|
|
|
110765
112544
|
file: "tickets.ts",
|
|
110766
112545
|
factory: "createTicketTools"
|
|
110767
112546
|
},
|
|
112547
|
+
{
|
|
112548
|
+
file: "sla-alerts.ts",
|
|
112549
|
+
factory: "createSlaAlertTools"
|
|
112550
|
+
},
|
|
110768
112551
|
{
|
|
110769
112552
|
file: "requests.ts",
|
|
110770
112553
|
factory: "createRequestTools"
|
|
@@ -110780,6 +112563,10 @@ const TOOL_MODULES = [
|
|
|
110780
112563
|
{
|
|
110781
112564
|
file: "users.ts",
|
|
110782
112565
|
factory: "createUserTools"
|
|
112566
|
+
},
|
|
112567
|
+
{
|
|
112568
|
+
file: "preferences.ts",
|
|
112569
|
+
factory: "createPreferenceTools"
|
|
110783
112570
|
}
|
|
110784
112571
|
];
|
|
110785
112572
|
/**
|
|
@@ -110806,22 +112593,29 @@ const loadFreshTools = async (ctx) => {
|
|
|
110806
112593
|
* only matters after an edit); `reload()` disposes the current generation and
|
|
110807
112594
|
* registers a fresh one.
|
|
110808
112595
|
*/
|
|
110809
|
-
const createReloadableServer = (config, getToken, logger = silentLogger, onUnauthorized, loadTools = loadFreshTools, requestStateCodec) => {
|
|
112596
|
+
const createReloadableServer = (config, getToken, logger = silentLogger, onUnauthorized, loadTools = loadFreshTools, requestStateCodec, slaRuntime = createSlaRuntime(config), listens, getBackgroundToken) => {
|
|
110810
112597
|
const server = createServerShell(config, requestStateCodec);
|
|
110811
112598
|
const caches = createServerCaches(config, getToken, onUnauthorized);
|
|
110812
|
-
const
|
|
110813
|
-
|
|
110814
|
-
brandIds: config.brandIds,
|
|
110815
|
-
resolveBrandSubdomain: caches.resolveBrandSubdomain,
|
|
110816
|
-
getToken
|
|
110817
|
-
};
|
|
112599
|
+
const sla = createSlaService(slaSettings(config), slaRuntime, caches.resolveUserId);
|
|
112600
|
+
const ctx = toolContext(config, getToken, caches.resolveBrandSubdomain, sla);
|
|
110818
112601
|
const params = {
|
|
110819
112602
|
config,
|
|
110820
112603
|
getToken,
|
|
110821
112604
|
caches,
|
|
110822
112605
|
onUnauthorized,
|
|
110823
|
-
logger
|
|
112606
|
+
logger,
|
|
112607
|
+
sla
|
|
110824
112608
|
};
|
|
112609
|
+
if (config.slaPush && slaAlertsEnabled(config)) {
|
|
112610
|
+
const reader = createAlertPollingReader({
|
|
112611
|
+
config,
|
|
112612
|
+
...pollingIdentity(config, getToken, caches, getBackgroundToken),
|
|
112613
|
+
caches,
|
|
112614
|
+
slaRuntime,
|
|
112615
|
+
onUnauthorized
|
|
112616
|
+
});
|
|
112617
|
+
installAlertSubscriptions(server, config, reader, logger, listens);
|
|
112618
|
+
}
|
|
110825
112619
|
let currentTools = createAllTools(ctx);
|
|
110826
112620
|
let current = registerToolset(server, params, currentTools);
|
|
110827
112621
|
const reload = async () => {
|
|
@@ -110896,9 +112690,9 @@ const registerReloadTool = (server, reload, logger = silentLogger) => {
|
|
|
110896
112690
|
*/
|
|
110897
112691
|
/* v8 ignore start -- runtime bootstrap: binds the reload tool to a real stdio
|
|
110898
112692
|
transport; the reload machinery it wires up is covered by dev-reload.test.ts */
|
|
110899
|
-
const startDevServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
110900
|
-
const handle = startStdioTransport(({ requestStateCodec }) => {
|
|
110901
|
-
const { server, reload } = createReloadableServer(config, getToken, logger, onUnauthorized, loadFreshTools, requestStateCodec);
|
|
112693
|
+
const startDevServer = (config, getToken, logger = silentLogger, onUnauthorized, slaRuntime, getBackgroundToken) => {
|
|
112694
|
+
const handle = startStdioTransport(({ requestStateCodec, listens }) => {
|
|
112695
|
+
const { server, reload } = createReloadableServer(config, getToken, logger, onUnauthorized, loadFreshTools, requestStateCodec, slaRuntime, listens, getBackgroundToken);
|
|
110902
112696
|
registerReloadTool(server, reload, logger);
|
|
110903
112697
|
return server;
|
|
110904
112698
|
}, logger);
|
|
@@ -111055,7 +112849,7 @@ const installShutdown = (options) => {
|
|
|
111055
112849
|
//#endregion
|
|
111056
112850
|
//#region src/index.ts
|
|
111057
112851
|
const buildStdioTokenStore = async (config, logger) => {
|
|
111058
|
-
const { createTokenStore } = await import("./token-store-
|
|
112852
|
+
const { createTokenStore } = await import("./token-store-CQx_KFe2.js");
|
|
111059
112853
|
return createTokenStore({
|
|
111060
112854
|
subdomain: config.subdomain,
|
|
111061
112855
|
oauthClientId: config.oauthClientId,
|
|
@@ -111063,13 +112857,15 @@ const buildStdioTokenStore = async (config, logger) => {
|
|
|
111063
112857
|
readOnly: config.readOnly
|
|
111064
112858
|
}, logger);
|
|
111065
112859
|
};
|
|
111066
|
-
const connectStdio = (config, tokenStore, logger) => {
|
|
111067
|
-
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
112860
|
+
const connectStdio = (config, tokenStore, logger, slaRuntime) => {
|
|
112861
|
+
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate, slaRuntime, tokenStore.getTokenSilently);
|
|
111068
112862
|
return startStdioTransport((extras) => defaultServerFactory({
|
|
111069
112863
|
config,
|
|
111070
112864
|
getToken: tokenStore.getToken,
|
|
111071
112865
|
logger,
|
|
111072
112866
|
onUnauthorized: tokenStore.invalidate,
|
|
112867
|
+
getBackgroundToken: tokenStore.getTokenSilently,
|
|
112868
|
+
slaRuntime,
|
|
111073
112869
|
...extras
|
|
111074
112870
|
}), logger);
|
|
111075
112871
|
};
|
|
@@ -111088,11 +112884,13 @@ const main = async () => {
|
|
|
111088
112884
|
const logger = createLogger(config.logLevel);
|
|
111089
112885
|
if (config.transport === "stdio") {
|
|
111090
112886
|
const tokenStore = await buildStdioTokenStore(config, logger);
|
|
111091
|
-
const
|
|
112887
|
+
const sla = startSlaRuntime(config);
|
|
112888
|
+
const stdio = connectStdio(config, tokenStore, logger, sla.runtime);
|
|
111092
112889
|
installShutdown({
|
|
111093
112890
|
watchStdin: true,
|
|
111094
112891
|
logger,
|
|
111095
112892
|
cleanup: async () => {
|
|
112893
|
+
sla.stop();
|
|
111096
112894
|
await stdio.close();
|
|
111097
112895
|
tokenStore.dispose();
|
|
111098
112896
|
}
|
|
@@ -111100,7 +112898,7 @@ const main = async () => {
|
|
|
111100
112898
|
return;
|
|
111101
112899
|
}
|
|
111102
112900
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
111103
|
-
const { startHttpTransport } = await import("./http-
|
|
112901
|
+
const { startHttpTransport } = await import("./http-DDdso3Av.js");
|
|
111104
112902
|
const http = await startHttpTransport(config, logger);
|
|
111105
112903
|
installShutdown({
|
|
111106
112904
|
watchStdin: false,
|
|
@@ -111114,4 +112912,4 @@ main().catch((error) => {
|
|
|
111114
112912
|
process.exit(1);
|
|
111115
112913
|
});
|
|
111116
112914
|
//#endregion
|
|
111117
|
-
export {
|
|
112915
|
+
export { __commonJSMin$1 as A, isLegacyRequest as C, STARTUP_DOCS as D, OAuthErrorCode as E, __require as M, __toESM$1 as N, createStartupError as O, createMcpHandler as S, OAuthError as T, deadlineSignal as _, watchGrantAlerts as a, DEFAULT_MAX_REQUEST_BODY_SIZE as b, configDir as c, saveToken as d, signInRequiredError as f, REQUEST_TIMEOUT_MS as g, zendeskGet as h, defaultServerFactory as i, __exportAll$1 as j, errnoCode as k, loadToken as l, silentLogger as m, listenedUris as n, startSlaRuntime as o, createRequestStateCodec as p, createServerCaches as r, clearToken as s, createListenFeed as t, resolveTokenPath as u, DEFAULT_CALLBACK_PORT as v, verifyBearerToken as w, WebStandardStreamableHTTPServerTransport as x, getOAuthUrls as y };
|