@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.
- package/dist/index.js +194 -23
- 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
|
-
|
|
821
|
-
|
|
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
|
|
834
|
-
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
|
+
}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
4597
|
-
|
|
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.
|
|
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",
|