@fruggr/zendesk-mcp-server 2.17.1 → 2.18.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/dist/index.js +229 -46
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -126,7 +126,6 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
126
126
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
127
127
|
};
|
|
128
128
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
129
|
-
const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
|
|
130
129
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
131
130
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
132
131
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
@@ -813,26 +812,195 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
813
812
|
});
|
|
814
813
|
};
|
|
815
814
|
//#endregion
|
|
815
|
+
//#region src/client/retry.ts
|
|
816
|
+
const MAX_ATTEMPTS = 3;
|
|
817
|
+
const BASE_DELAY_MS = 250;
|
|
818
|
+
const MAX_DELAY_MS = 4e3;
|
|
819
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
820
|
+
const TRANSFER_TIMEOUT_MS = 12e4;
|
|
821
|
+
/**
|
|
822
|
+
* A deadline for one attempt. The abort surfaces as a `TimeoutError` whose `code`
|
|
823
|
+
* is numeric, not a syscall string, so it classifies as `unknown`: a GET is
|
|
824
|
+
* retried, a write is not — the request may have arrived before the deadline.
|
|
825
|
+
*/
|
|
826
|
+
const deadlineSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs);
|
|
827
|
+
const MAX_RETRY_AFTER_MS = 5e3;
|
|
828
|
+
const POLICIES = {
|
|
829
|
+
GET: {
|
|
830
|
+
network: "any",
|
|
831
|
+
serverErrors: true
|
|
832
|
+
},
|
|
833
|
+
DELETE: {
|
|
834
|
+
network: "pre-send",
|
|
835
|
+
serverErrors: false
|
|
836
|
+
},
|
|
837
|
+
POST: {
|
|
838
|
+
network: "pre-send",
|
|
839
|
+
serverErrors: false
|
|
840
|
+
},
|
|
841
|
+
PUT: {
|
|
842
|
+
network: "pre-send",
|
|
843
|
+
serverErrors: false
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
const DELAY_SECONDS = /^\d+$/;
|
|
847
|
+
const HTTP_DATE_START = /^[A-Za-z]/;
|
|
848
|
+
const NON_ASCII = /[^ -~]/g;
|
|
849
|
+
const TOKEN_SEGMENT = /\/token\/[^/]+/;
|
|
850
|
+
const URL_IN_TEXT = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
|
851
|
+
const PRE_SEND_CODES = /* @__PURE__ */ new Set([
|
|
852
|
+
"ENOTFOUND",
|
|
853
|
+
"EAI_AGAIN",
|
|
854
|
+
"ECONNREFUSED",
|
|
855
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
856
|
+
]);
|
|
857
|
+
const defaultRetryDeps = {
|
|
858
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
859
|
+
random: Math.random
|
|
860
|
+
};
|
|
861
|
+
/** Non-ASCII bytes break `node:http` headers, so client error text stays ASCII. */
|
|
862
|
+
const toAscii = (text) => text.replace(NON_ASCII, "?");
|
|
863
|
+
/**
|
|
864
|
+
* Identifies the request without leaking a credential: uploads carry an upload
|
|
865
|
+
* token in the query string, and an attachment `content_url` carries a download
|
|
866
|
+
* token as a path segment (`/attachments/token/<token>/`).
|
|
867
|
+
*/
|
|
868
|
+
const describeTarget = (url) => {
|
|
869
|
+
const { origin, pathname } = new URL(url);
|
|
870
|
+
return `${origin}${pathname.replace(TOKEN_SEGMENT, "/token/[redacted]")}`;
|
|
871
|
+
};
|
|
872
|
+
/** First `code` in the cause chain, inspecting 5 levels so a cycle cannot hang. */
|
|
873
|
+
const errorCode = (err) => {
|
|
874
|
+
let current = err;
|
|
875
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth += 1) {
|
|
876
|
+
const { code, cause } = current;
|
|
877
|
+
if (typeof code === "string") return code;
|
|
878
|
+
current = cause;
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
const classifyNetworkError = (err) => PRE_SEND_CODES.has(errorCode(err)) ? "pre-send" : "unknown";
|
|
882
|
+
const UNINFORMATIVE_NAMES = /* @__PURE__ */ new Set(["Error", "TypeError"]);
|
|
883
|
+
/**
|
|
884
|
+
* The client refuses to replay a write that may have landed — but the caller is
|
|
885
|
+
* an LLM whose reflex on a failed tool call is to try again, which would undo
|
|
886
|
+
* that. So a failed write says whether it may have taken effect. ASCII only,
|
|
887
|
+
* same rule as the rest of the message.
|
|
888
|
+
*/
|
|
889
|
+
const MAY_HAVE_APPLIED_NOTE = "The write may already have been applied. Check the current state before retrying, or you may duplicate it.";
|
|
890
|
+
const NEVER_SENT_NOTE = "The request never reached Zendesk, so nothing was applied. Retrying is safe.";
|
|
891
|
+
const REFUSED_NOTE = "Zendesk refused the request, so nothing was applied. Retrying is safe.";
|
|
892
|
+
/** Only a write can be duplicated by a replay, so only a write carries a note. */
|
|
893
|
+
const writeNote = (method, note) => method === "GET" ? "" : ` ${note}`;
|
|
894
|
+
/**
|
|
895
|
+
* A cause message can quote the URL it failed on — Node's `fetch` refuses a URL
|
|
896
|
+
* carrying credentials by printing the whole thing, query string included — which
|
|
897
|
+
* would smuggle back exactly what `describeTarget` drops. Same treatment for both,
|
|
898
|
+
* so one function owns what a URL may look like in our output.
|
|
899
|
+
*/
|
|
900
|
+
const redactUrls = (text) => text.replace(URL_IN_TEXT, (found) => {
|
|
901
|
+
try {
|
|
902
|
+
return new URL(found).origin === "null" ? "[url]" : describeTarget(found);
|
|
903
|
+
} catch {
|
|
904
|
+
return "[url]";
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
const failureDetail = (cause) => {
|
|
908
|
+
if (!(cause instanceof Error)) return redactUrls(String(cause));
|
|
909
|
+
const message = redactUrls(cause.message);
|
|
910
|
+
const code = errorCode(cause);
|
|
911
|
+
if (code !== void 0) return `${code}: ${message}`;
|
|
912
|
+
return UNINFORMATIVE_NAMES.has(cause.name) ? message : `${cause.name}: ${message}`;
|
|
913
|
+
};
|
|
914
|
+
const networkErrorMessage = (method, target, attempts, cause) => {
|
|
915
|
+
const base = `Network error on ${method} ${target} after ${attempts === 1 ? "1 attempt" : `${attempts} attempts`}: ${failureDetail(cause)}`;
|
|
916
|
+
const note = classifyNetworkError(cause) === "pre-send" ? NEVER_SENT_NOTE : MAY_HAVE_APPLIED_NOTE;
|
|
917
|
+
return toAscii(method === "GET" ? base : `${base}.${writeNote(method, note)}`);
|
|
918
|
+
};
|
|
919
|
+
const createZendeskNetworkError = (method, target, attempts, cause) => Object.assign(new Error(networkErrorMessage(method, target, attempts, cause), { cause }), {
|
|
920
|
+
name: "ZendeskNetworkError",
|
|
921
|
+
method,
|
|
922
|
+
target,
|
|
923
|
+
attempts
|
|
924
|
+
});
|
|
925
|
+
/** Exponential backoff with equal jitter: half the window fixed, half random. */
|
|
926
|
+
const computeBackoffMs = (attempt, random) => {
|
|
927
|
+
const window = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
|
928
|
+
return Math.round(window / 2 + window * random() / 2);
|
|
929
|
+
};
|
|
930
|
+
/** `Retry-After` in ms — delay-seconds or HTTP-date. */
|
|
931
|
+
const parseRetryAfter = (header, now = Date.now()) => {
|
|
932
|
+
if (header === null) return void 0;
|
|
933
|
+
const value = header.trim();
|
|
934
|
+
if (DELAY_SECONDS.test(value)) return Number(value) * 1e3;
|
|
935
|
+
if (!HTTP_DATE_START.test(value)) return void 0;
|
|
936
|
+
const date = Date.parse(value);
|
|
937
|
+
if (Number.isNaN(date)) return void 0;
|
|
938
|
+
return Math.max(0, date - now);
|
|
939
|
+
};
|
|
940
|
+
/**
|
|
941
|
+
* How long to wait before replaying this response, or undefined to accept it.
|
|
942
|
+
* `Retry-After` wins over backoff wherever it appears — Zendesk sends it on a 503
|
|
943
|
+
* during maintenance as well as on a 429 — and a value past the cap means the
|
|
944
|
+
* response is surfaced rather than parking the call for that long.
|
|
945
|
+
*/
|
|
946
|
+
const retryDelayFor = (response, policy, attempt, random) => {
|
|
947
|
+
if (!(response.status === 429 || response.status >= 500 && policy.serverErrors)) return void 0;
|
|
948
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
949
|
+
if (retryAfter === void 0) return computeBackoffMs(attempt, random);
|
|
950
|
+
return retryAfter > MAX_RETRY_AFTER_MS ? void 0 : retryAfter;
|
|
951
|
+
};
|
|
952
|
+
/**
|
|
953
|
+
* Runs `attempt` until it succeeds, hits a terminal outcome, or spends the
|
|
954
|
+
* attempt budget. Returns the last response for the caller to inspect (a
|
|
955
|
+
* non-ok status is still the caller's to turn into a `ZendeskApiError`), and
|
|
956
|
+
* throws `ZendeskNetworkError` when no response was ever obtained.
|
|
957
|
+
*/
|
|
958
|
+
const fetchWithRetry = async (attempt, method, target, deps = defaultRetryDeps) => {
|
|
959
|
+
const policy = POLICIES[method];
|
|
960
|
+
for (let tries = 1;; tries += 1) {
|
|
961
|
+
const last = tries >= MAX_ATTEMPTS;
|
|
962
|
+
let response;
|
|
963
|
+
try {
|
|
964
|
+
response = await attempt();
|
|
965
|
+
} catch (err) {
|
|
966
|
+
const replayable = policy.network === "any" || classifyNetworkError(err) === policy.network;
|
|
967
|
+
if (last || !replayable) throw createZendeskNetworkError(method, target, tries, err);
|
|
968
|
+
await deps.sleep(computeBackoffMs(tries, deps.random));
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (last) return response;
|
|
972
|
+
const delayMs = retryDelayFor(response, policy, tries, deps.random);
|
|
973
|
+
if (delayMs === void 0) return response;
|
|
974
|
+
await response.text().catch(() => void 0);
|
|
975
|
+
await deps.sleep(delayMs);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
//#endregion
|
|
816
979
|
//#region src/client/zendesk-api.ts
|
|
817
980
|
var ZendeskApiError = class ZendeskApiError extends Error {
|
|
818
981
|
status;
|
|
819
982
|
statusText;
|
|
820
983
|
body;
|
|
821
|
-
|
|
822
|
-
|
|
984
|
+
method;
|
|
985
|
+
constructor(status, statusText, body, method) {
|
|
986
|
+
super(ZendeskApiError.buildMessage(status, statusText, body, method));
|
|
823
987
|
this.status = status;
|
|
824
988
|
this.statusText = statusText;
|
|
825
989
|
this.body = body;
|
|
990
|
+
this.method = method;
|
|
826
991
|
this.name = "ZendeskApiError";
|
|
827
992
|
}
|
|
828
|
-
static buildMessage(status, statusText, body) {
|
|
993
|
+
static buildMessage(status, statusText, body, method) {
|
|
829
994
|
switch (status) {
|
|
830
995
|
case 401: return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
|
|
831
996
|
case 403: return "Permission denied. Your Zendesk account does not have access to this resource.";
|
|
832
997
|
case 404: return `Resource not found. Please verify the ID is correct. (${statusText})`;
|
|
833
998
|
case 422: return `Validation error: ${body}`;
|
|
834
|
-
case 429: return
|
|
835
|
-
default:
|
|
999
|
+
case 429: return `Rate limit exceeded. Please wait before making more requests.${writeNote(method, REFUSED_NOTE)}`;
|
|
1000
|
+
default: {
|
|
1001
|
+
const message = `Zendesk API error ${status}: ${statusText}. ${body}`;
|
|
1002
|
+
return status >= 500 ? `${message}${writeNote(method, MAY_HAVE_APPLIED_NOTE)}` : message;
|
|
1003
|
+
}
|
|
836
1004
|
}
|
|
837
1005
|
}
|
|
838
1006
|
};
|
|
@@ -842,6 +1010,11 @@ const buildUrl = (base, path, params) => {
|
|
|
842
1010
|
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
843
1011
|
return url.toString();
|
|
844
1012
|
};
|
|
1013
|
+
const performFetch = (method, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => fetchWithRetry(() => fetch(url, {
|
|
1014
|
+
...init,
|
|
1015
|
+
method,
|
|
1016
|
+
signal: deadlineSignal(timeoutMs)
|
|
1017
|
+
}), method, describeTarget(url));
|
|
845
1018
|
const executeRequest = async (url, token, options = {}) => {
|
|
846
1019
|
const { method = "GET", body } = options;
|
|
847
1020
|
const headers = {
|
|
@@ -849,15 +1022,12 @@ const executeRequest = async (url, token, options = {}) => {
|
|
|
849
1022
|
Accept: "application/json"
|
|
850
1023
|
};
|
|
851
1024
|
if (body) headers["Content-Type"] = "application/json";
|
|
852
|
-
const init = {
|
|
853
|
-
method,
|
|
854
|
-
headers
|
|
855
|
-
};
|
|
1025
|
+
const init = { headers };
|
|
856
1026
|
if (body) init.body = JSON.stringify(body);
|
|
857
|
-
const response = await
|
|
1027
|
+
const response = await performFetch(method, url, init);
|
|
858
1028
|
if (!response.ok) {
|
|
859
1029
|
const responseBody = await response.text();
|
|
860
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1030
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, method);
|
|
861
1031
|
}
|
|
862
1032
|
if (response.status === 204) return {};
|
|
863
1033
|
return response.json();
|
|
@@ -906,10 +1076,10 @@ const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
|
|
|
906
1076
|
const expectedHost = `${subdomain}.zendesk.com`;
|
|
907
1077
|
const headers = {};
|
|
908
1078
|
if (new URL(contentUrl).hostname === expectedHost) headers["Authorization"] = buildAuthHeader(token);
|
|
909
|
-
const response = await
|
|
1079
|
+
const response = await performFetch("GET", contentUrl, { headers }, TRANSFER_TIMEOUT_MS);
|
|
910
1080
|
if (!response.ok) {
|
|
911
1081
|
const body = await response.text();
|
|
912
|
-
throw new ZendeskApiError(response.status, response.statusText, body);
|
|
1082
|
+
throw new ZendeskApiError(response.status, response.statusText, body, "GET");
|
|
913
1083
|
}
|
|
914
1084
|
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
915
1085
|
const arrayBuffer = await response.arrayBuffer();
|
|
@@ -922,30 +1092,28 @@ const zendeskUpload = async (subdomain, token, filename, data, contentType, uplo
|
|
|
922
1092
|
const params = { filename };
|
|
923
1093
|
if (uploadToken) params["token"] = uploadToken;
|
|
924
1094
|
const url = buildUrl(getBaseUrl(subdomain), "/uploads", params);
|
|
925
|
-
const response = await
|
|
926
|
-
method: "POST",
|
|
1095
|
+
const response = await performFetch("POST", url, {
|
|
927
1096
|
headers: {
|
|
928
1097
|
Authorization: buildAuthHeader(token),
|
|
929
1098
|
"Content-Type": contentType
|
|
930
1099
|
},
|
|
931
1100
|
body: data
|
|
932
|
-
});
|
|
1101
|
+
}, TRANSFER_TIMEOUT_MS);
|
|
933
1102
|
if (!response.ok) {
|
|
934
1103
|
const responseBody = await response.text();
|
|
935
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1104
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
|
|
936
1105
|
}
|
|
937
1106
|
return response.json();
|
|
938
1107
|
};
|
|
939
1108
|
const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
940
1109
|
const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
|
|
941
|
-
const response = await
|
|
942
|
-
method: "POST",
|
|
1110
|
+
const response = await performFetch("POST", url, {
|
|
943
1111
|
headers: { Authorization: buildAuthHeader(token) },
|
|
944
1112
|
body: formData
|
|
945
|
-
});
|
|
1113
|
+
}, TRANSFER_TIMEOUT_MS);
|
|
946
1114
|
if (!response.ok) {
|
|
947
1115
|
const responseBody = await response.text();
|
|
948
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1116
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
|
|
949
1117
|
}
|
|
950
1118
|
return response.json();
|
|
951
1119
|
};
|
|
@@ -1802,6 +1970,7 @@ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
|
|
|
1802
1970
|
"",
|
|
1803
1971
|
formatNodeTranslationSummary(translation)
|
|
1804
1972
|
].join("\n");
|
|
1973
|
+
const TRANSLATIONS_SIDELOAD = "translations";
|
|
1805
1974
|
const GAP_REASON_TEXT = {
|
|
1806
1975
|
missing: "no translation",
|
|
1807
1976
|
draft: "draft translation (not published)"
|
|
@@ -1819,40 +1988,49 @@ const classifyGap = (node, translations, locale) => {
|
|
|
1819
1988
|
reason: "draft"
|
|
1820
1989
|
} : null;
|
|
1821
1990
|
};
|
|
1991
|
+
/**
|
|
1992
|
+
* Split a listing into the nodes that can be classified and the rest. A node
|
|
1993
|
+
* whose `translations` key is absent was answered without the sideload, and
|
|
1994
|
+
* reading that as "no translation" would report an entire Help Center as one big
|
|
1995
|
+
* gap — so it counts as unclassified and the report says how many.
|
|
1996
|
+
*/
|
|
1997
|
+
const withSideloadedTranslations = (nodes) => nodes.filter((node) => Array.isArray(node.translations));
|
|
1822
1998
|
const renderGapLines = (heading, gaps, scanned, found) => {
|
|
1823
1999
|
const header = `## ${heading} (${scanned} scanned)`;
|
|
1824
2000
|
if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
|
|
1825
|
-
if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none
|
|
2001
|
+
if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none classified — the listing answered without the sideload for all ${found}, see the note below)_`];
|
|
1826
2002
|
return [header, "_(none — every one scanned has a published translation)_"];
|
|
1827
2003
|
};
|
|
1828
|
-
const GAP_SCAN_WAVE_SIZE = 5;
|
|
1829
|
-
const probeInWaves = async (nodes, probe) => {
|
|
1830
|
-
const gaps = [];
|
|
1831
|
-
for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
|
|
1832
|
-
const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
|
|
1833
|
-
for (const gap of wave) if (gap !== null) gaps.push(gap);
|
|
1834
|
-
}
|
|
1835
|
-
return gaps;
|
|
1836
|
-
};
|
|
1837
2004
|
const fetchGapCategories = async (subdomain, token, categoryId) => {
|
|
1838
2005
|
if (categoryId !== void 0) {
|
|
1839
|
-
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}
|
|
2006
|
+
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`, { include: TRANSLATIONS_SIDELOAD });
|
|
1840
2007
|
return {
|
|
1841
2008
|
categories: [category],
|
|
1842
2009
|
hasMore: false
|
|
1843
2010
|
};
|
|
1844
2011
|
}
|
|
1845
|
-
const response = await helpCenterGet(subdomain, token, "/categories",
|
|
2012
|
+
const response = await helpCenterGet(subdomain, token, "/categories", {
|
|
2013
|
+
...buildCursorParams(100, void 0),
|
|
2014
|
+
include: TRANSLATIONS_SIDELOAD
|
|
2015
|
+
});
|
|
1846
2016
|
const categories = response.categories ?? [];
|
|
1847
2017
|
return {
|
|
1848
2018
|
categories,
|
|
1849
2019
|
hasMore: extractPaginationMeta(response, categories.length).has_more
|
|
1850
2020
|
};
|
|
1851
2021
|
};
|
|
2022
|
+
const renderGapVerdict = (report, gapCount, unclassified) => {
|
|
2023
|
+
const { locale, scanned } = report;
|
|
2024
|
+
if (gapCount > 0) return `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`;
|
|
2025
|
+
if (scanned.categories + scanned.sections === 0 && unclassified > 0) return `Nothing could be classified, so this audit says nothing about "${locale}" either way. See the note below.`;
|
|
2026
|
+
const allClear = `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.`;
|
|
2027
|
+
return unclassified > 0 ? `${allClear} This is not a clean bill of health for the whole tree: ${unclassified} other node(s) could not be classified — see the note below.` : allClear;
|
|
2028
|
+
};
|
|
1852
2029
|
const renderGapReport = (report) => {
|
|
1853
2030
|
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
1854
2031
|
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
1855
|
-
const
|
|
2032
|
+
const totalFound = found.categories + found.sections;
|
|
2033
|
+
const unclassified = totalFound - (scanned.categories + scanned.sections);
|
|
1856
2034
|
return truncateIfNeeded([
|
|
1857
2035
|
`# Translation gaps — "${locale}"`,
|
|
1858
2036
|
"",
|
|
@@ -1861,8 +2039,8 @@ const renderGapReport = (report) => {
|
|
|
1861
2039
|
"",
|
|
1862
2040
|
...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
|
|
1863
2041
|
"",
|
|
1864
|
-
|
|
1865
|
-
...
|
|
2042
|
+
renderGapVerdict(report, gapCount, unclassified),
|
|
2043
|
+
...unclassified > 0 ? ["", `_Note: ${unclassified} of ${totalFound} node(s) came back without the \`translations\` sideload, so their state is unknown and none of them is reported above (covered: ${scanned.categories}/${found.categories} categories, ${scanned.sections}/${found.sections} sections). Read one of them with list_category_translations / list_section_translations, which query the node directly._`] : [],
|
|
1866
2044
|
...report.listingIncomplete ? ["", `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
|
|
1867
2045
|
].join("\n"));
|
|
1868
2046
|
};
|
|
@@ -2302,10 +2480,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2302
2480
|
namespace: "help_center",
|
|
2303
2481
|
readOnly: true,
|
|
2304
2482
|
title: "Find Help Center Translation Gaps",
|
|
2305
|
-
description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs
|
|
2483
|
+
description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs two listings and covers up to 100 categories and 100 sections; past that only the first page of each level is audited, and the report says so — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
|
|
2306
2484
|
inputSchema: z.object({
|
|
2307
2485
|
locale: z.string().describe("Locale to audit, e.g. \"fr\" or \"de\" — usually a non-default active locale of the Help Center (zendesk-hc://topology lists them). A locale that is not active is reported as a warning, since every node would then look untranslated."),
|
|
2308
|
-
category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs
|
|
2486
|
+
category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs two listings and covers up to 100 categories and 100 sections.")
|
|
2309
2487
|
}),
|
|
2310
2488
|
annotations: {
|
|
2311
2489
|
readOnlyHint: true,
|
|
@@ -2319,14 +2497,17 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2319
2497
|
const [locales, categoryScope, sectionsRes] = await Promise.all([
|
|
2320
2498
|
helpCenterGet(subdomain, token, "/locales"),
|
|
2321
2499
|
fetchGapCategories(subdomain, token, category_id),
|
|
2322
|
-
helpCenterGet(subdomain, token, sectionListPath(category_id, void 0),
|
|
2500
|
+
helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), {
|
|
2501
|
+
...buildCursorParams(100, void 0),
|
|
2502
|
+
include: TRANSLATIONS_SIDELOAD
|
|
2503
|
+
})
|
|
2323
2504
|
]);
|
|
2324
2505
|
const allCategories = categoryScope.categories;
|
|
2325
2506
|
const allSections = sectionsRes.sections ?? [];
|
|
2326
|
-
const categories = allCategories
|
|
2327
|
-
const sections = allSections
|
|
2328
|
-
const categoryGaps =
|
|
2329
|
-
const sectionGaps =
|
|
2507
|
+
const categories = withSideloadedTranslations(allCategories);
|
|
2508
|
+
const sections = withSideloadedTranslations(allSections);
|
|
2509
|
+
const categoryGaps = categories.flatMap((category) => classifyGap(category, category.translations, locale) ?? []);
|
|
2510
|
+
const sectionGaps = sections.flatMap((section) => classifyGap(section, section.translations, locale) ?? []);
|
|
2330
2511
|
return { content: [{
|
|
2331
2512
|
type: "text",
|
|
2332
2513
|
text: renderGapReport({
|
|
@@ -4581,8 +4762,10 @@ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
|
|
|
4581
4762
|
const respondBodyError = (req, res, failure) => {
|
|
4582
4763
|
const headers = failure.status === 413 ? { Connection: "close" } : {};
|
|
4583
4764
|
sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
|
|
4584
|
-
if (failure.status === 413)
|
|
4585
|
-
|
|
4765
|
+
if (failure.status === 413) {
|
|
4766
|
+
if (res.writableFinished) req.destroy();
|
|
4767
|
+
else res.once("finish", () => req.destroy());
|
|
4768
|
+
}
|
|
4586
4769
|
};
|
|
4587
4770
|
const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
4588
4771
|
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"mcpName": "io.github.fruggr/zendesk-mcp-server",
|
|
5
5
|
"description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
|
|
6
6
|
"type": "module",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=20"
|
|
71
71
|
},
|
|
72
|
-
"packageManager": "pnpm@11.
|
|
72
|
+
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
75
75
|
"cheerio": "1.2.0",
|
|
@@ -89,9 +89,9 @@
|
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@biomejs/biome": "2.5.7",
|
|
92
|
-
"@semantic-release/changelog": "^
|
|
92
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
93
93
|
"@semantic-release/exec": "^7.1.0",
|
|
94
|
-
"@semantic-release/git": "^
|
|
94
|
+
"@semantic-release/git": "^11.0.0",
|
|
95
95
|
"@semantic-release/github": "^12.0.6",
|
|
96
96
|
"@semantic-release/npm": "^13.1.5",
|
|
97
97
|
"@semantic-release/release-notes-generator": "^14.1.1",
|