@fruggr/zendesk-mcp-server 2.17.2 → 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.
Files changed (2) hide show
  1. package/dist/index.js +194 -23
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -812,26 +812,195 @@ const loadConfig = (argv = process.argv.slice(2)) => {
812
812
  });
813
813
  };
814
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
815
979
  //#region src/client/zendesk-api.ts
816
980
  var ZendeskApiError = class ZendeskApiError extends Error {
817
981
  status;
818
982
  statusText;
819
983
  body;
820
- constructor(status, statusText, body) {
821
- super(ZendeskApiError.buildMessage(status, statusText, body));
984
+ method;
985
+ constructor(status, statusText, body, method) {
986
+ super(ZendeskApiError.buildMessage(status, statusText, body, method));
822
987
  this.status = status;
823
988
  this.statusText = statusText;
824
989
  this.body = body;
990
+ this.method = method;
825
991
  this.name = "ZendeskApiError";
826
992
  }
827
- static buildMessage(status, statusText, body) {
993
+ static buildMessage(status, statusText, body, method) {
828
994
  switch (status) {
829
995
  case 401: return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
830
996
  case 403: return "Permission denied. Your Zendesk account does not have access to this resource.";
831
997
  case 404: return `Resource not found. Please verify the ID is correct. (${statusText})`;
832
998
  case 422: return `Validation error: ${body}`;
833
- case 429: return "Rate limit exceeded. Please wait before making more requests.";
834
- default: return `Zendesk API error ${status}: ${statusText}. ${body}`;
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
+ }
835
1004
  }
836
1005
  }
837
1006
  };
@@ -841,6 +1010,11 @@ const buildUrl = (base, path, params) => {
841
1010
  if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
842
1011
  return url.toString();
843
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));
844
1018
  const executeRequest = async (url, token, options = {}) => {
845
1019
  const { method = "GET", body } = options;
846
1020
  const headers = {
@@ -848,15 +1022,12 @@ const executeRequest = async (url, token, options = {}) => {
848
1022
  Accept: "application/json"
849
1023
  };
850
1024
  if (body) headers["Content-Type"] = "application/json";
851
- const init = {
852
- method,
853
- headers
854
- };
1025
+ const init = { headers };
855
1026
  if (body) init.body = JSON.stringify(body);
856
- const response = await fetch(url, init);
1027
+ const response = await performFetch(method, url, init);
857
1028
  if (!response.ok) {
858
1029
  const responseBody = await response.text();
859
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1030
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, method);
860
1031
  }
861
1032
  if (response.status === 204) return {};
862
1033
  return response.json();
@@ -905,10 +1076,10 @@ const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
905
1076
  const expectedHost = `${subdomain}.zendesk.com`;
906
1077
  const headers = {};
907
1078
  if (new URL(contentUrl).hostname === expectedHost) headers["Authorization"] = buildAuthHeader(token);
908
- const response = await fetch(contentUrl, { headers });
1079
+ const response = await performFetch("GET", contentUrl, { headers }, TRANSFER_TIMEOUT_MS);
909
1080
  if (!response.ok) {
910
1081
  const body = await response.text();
911
- throw new ZendeskApiError(response.status, response.statusText, body);
1082
+ throw new ZendeskApiError(response.status, response.statusText, body, "GET");
912
1083
  }
913
1084
  const contentType = response.headers.get("content-type") ?? "application/octet-stream";
914
1085
  const arrayBuffer = await response.arrayBuffer();
@@ -921,30 +1092,28 @@ const zendeskUpload = async (subdomain, token, filename, data, contentType, uplo
921
1092
  const params = { filename };
922
1093
  if (uploadToken) params["token"] = uploadToken;
923
1094
  const url = buildUrl(getBaseUrl(subdomain), "/uploads", params);
924
- const response = await fetch(url, {
925
- method: "POST",
1095
+ const response = await performFetch("POST", url, {
926
1096
  headers: {
927
1097
  Authorization: buildAuthHeader(token),
928
1098
  "Content-Type": contentType
929
1099
  },
930
1100
  body: data
931
- });
1101
+ }, TRANSFER_TIMEOUT_MS);
932
1102
  if (!response.ok) {
933
1103
  const responseBody = await response.text();
934
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1104
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
935
1105
  }
936
1106
  return response.json();
937
1107
  };
938
1108
  const helpCenterUpload = async (subdomain, token, path, formData) => {
939
1109
  const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
940
- const response = await fetch(url, {
941
- method: "POST",
1110
+ const response = await performFetch("POST", url, {
942
1111
  headers: { Authorization: buildAuthHeader(token) },
943
1112
  body: formData
944
- });
1113
+ }, TRANSFER_TIMEOUT_MS);
945
1114
  if (!response.ok) {
946
1115
  const responseBody = await response.text();
947
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1116
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
948
1117
  }
949
1118
  return response.json();
950
1119
  };
@@ -4593,8 +4762,10 @@ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
4593
4762
  const respondBodyError = (req, res, failure) => {
4594
4763
  const headers = failure.status === 413 ? { Connection: "close" } : {};
4595
4764
  sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
4596
- if (failure.status === 413) if (res.writableFinished) req.destroy();
4597
- else res.once("finish", () => req.destroy());
4765
+ if (failure.status === 413) {
4766
+ if (res.writableFinished) req.destroy();
4767
+ else res.once("finish", () => req.destroy());
4768
+ }
4598
4769
  };
4599
4770
  const SESSION_IDLE_TIMEOUT_MS = 18e5;
4600
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.17.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.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
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": "^6.0.3",
92
+ "@semantic-release/changelog": "^7.0.0",
93
93
  "@semantic-release/exec": "^7.1.0",
94
- "@semantic-release/git": "^10.0.1",
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",