@fruggr/zendesk-mcp-server 2.17.2 → 2.19.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 +304 -31
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -281,7 +281,6 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
281
281
|
});
|
|
282
282
|
const requestedPort = config.callbackPort ?? 27439;
|
|
283
283
|
const onStartError = (err) => {
|
|
284
|
-
clearTimeout(authTimeout);
|
|
285
284
|
const code = err.code;
|
|
286
285
|
logger.error("oauth_callback_listen_failed", {
|
|
287
286
|
port: requestedPort,
|
|
@@ -812,26 +811,195 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
812
811
|
});
|
|
813
812
|
};
|
|
814
813
|
//#endregion
|
|
814
|
+
//#region src/client/retry.ts
|
|
815
|
+
const MAX_ATTEMPTS = 3;
|
|
816
|
+
const BASE_DELAY_MS = 250;
|
|
817
|
+
const MAX_DELAY_MS = 4e3;
|
|
818
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
819
|
+
const TRANSFER_TIMEOUT_MS = 12e4;
|
|
820
|
+
/**
|
|
821
|
+
* A deadline for one attempt. The abort surfaces as a `TimeoutError` whose `code`
|
|
822
|
+
* is numeric, not a syscall string, so it classifies as `unknown`: a GET is
|
|
823
|
+
* retried, a write is not — the request may have arrived before the deadline.
|
|
824
|
+
*/
|
|
825
|
+
const deadlineSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs);
|
|
826
|
+
const MAX_RETRY_AFTER_MS = 5e3;
|
|
827
|
+
const POLICIES = {
|
|
828
|
+
GET: {
|
|
829
|
+
network: "any",
|
|
830
|
+
serverErrors: true
|
|
831
|
+
},
|
|
832
|
+
DELETE: {
|
|
833
|
+
network: "pre-send",
|
|
834
|
+
serverErrors: false
|
|
835
|
+
},
|
|
836
|
+
POST: {
|
|
837
|
+
network: "pre-send",
|
|
838
|
+
serverErrors: false
|
|
839
|
+
},
|
|
840
|
+
PUT: {
|
|
841
|
+
network: "pre-send",
|
|
842
|
+
serverErrors: false
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
const DELAY_SECONDS = /^\d+$/;
|
|
846
|
+
const HTTP_DATE_START = /^[A-Za-z]/;
|
|
847
|
+
const NON_ASCII = /[^ -~]/g;
|
|
848
|
+
const TOKEN_SEGMENT = /\/token\/[^/]+/;
|
|
849
|
+
const URL_IN_TEXT = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
|
850
|
+
const PRE_SEND_CODES = /* @__PURE__ */ new Set([
|
|
851
|
+
"ENOTFOUND",
|
|
852
|
+
"EAI_AGAIN",
|
|
853
|
+
"ECONNREFUSED",
|
|
854
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
855
|
+
]);
|
|
856
|
+
const defaultRetryDeps = {
|
|
857
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
858
|
+
random: Math.random
|
|
859
|
+
};
|
|
860
|
+
/** Non-ASCII bytes break `node:http` headers, so client error text stays ASCII. */
|
|
861
|
+
const toAscii = (text) => text.replace(NON_ASCII, "?");
|
|
862
|
+
/**
|
|
863
|
+
* Identifies the request without leaking a credential: uploads carry an upload
|
|
864
|
+
* token in the query string, and an attachment `content_url` carries a download
|
|
865
|
+
* token as a path segment (`/attachments/token/<token>/`).
|
|
866
|
+
*/
|
|
867
|
+
const describeTarget = (url) => {
|
|
868
|
+
const { origin, pathname } = new URL(url);
|
|
869
|
+
return `${origin}${pathname.replace(TOKEN_SEGMENT, "/token/[redacted]")}`;
|
|
870
|
+
};
|
|
871
|
+
/** First `code` in the cause chain, inspecting 5 levels so a cycle cannot hang. */
|
|
872
|
+
const errorCode = (err) => {
|
|
873
|
+
let current = err;
|
|
874
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth += 1) {
|
|
875
|
+
const { code, cause } = current;
|
|
876
|
+
if (typeof code === "string") return code;
|
|
877
|
+
current = cause;
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
const classifyNetworkError = (err) => PRE_SEND_CODES.has(errorCode(err)) ? "pre-send" : "unknown";
|
|
881
|
+
const UNINFORMATIVE_NAMES = /* @__PURE__ */ new Set(["Error", "TypeError"]);
|
|
882
|
+
/**
|
|
883
|
+
* The client refuses to replay a write that may have landed — but the caller is
|
|
884
|
+
* an LLM whose reflex on a failed tool call is to try again, which would undo
|
|
885
|
+
* that. So a failed write says whether it may have taken effect. ASCII only,
|
|
886
|
+
* same rule as the rest of the message.
|
|
887
|
+
*/
|
|
888
|
+
const MAY_HAVE_APPLIED_NOTE = "The write may already have been applied. Check the current state before retrying, or you may duplicate it.";
|
|
889
|
+
const NEVER_SENT_NOTE = "The request never reached Zendesk, so nothing was applied. Retrying is safe.";
|
|
890
|
+
const REFUSED_NOTE = "Zendesk refused the request, so nothing was applied. Retrying is safe.";
|
|
891
|
+
/** Only a write can be duplicated by a replay, so only a write carries a note. */
|
|
892
|
+
const writeNote = (method, note) => method === "GET" ? "" : ` ${note}`;
|
|
893
|
+
/**
|
|
894
|
+
* A cause message can quote the URL it failed on — Node's `fetch` refuses a URL
|
|
895
|
+
* carrying credentials by printing the whole thing, query string included — which
|
|
896
|
+
* would smuggle back exactly what `describeTarget` drops. Same treatment for both,
|
|
897
|
+
* so one function owns what a URL may look like in our output.
|
|
898
|
+
*/
|
|
899
|
+
const redactUrls = (text) => text.replace(URL_IN_TEXT, (found) => {
|
|
900
|
+
try {
|
|
901
|
+
return new URL(found).origin === "null" ? "[url]" : describeTarget(found);
|
|
902
|
+
} catch {
|
|
903
|
+
return "[url]";
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
const failureDetail = (cause) => {
|
|
907
|
+
if (!(cause instanceof Error)) return redactUrls(String(cause));
|
|
908
|
+
const message = redactUrls(cause.message);
|
|
909
|
+
const code = errorCode(cause);
|
|
910
|
+
if (code !== void 0) return `${code}: ${message}`;
|
|
911
|
+
return UNINFORMATIVE_NAMES.has(cause.name) ? message : `${cause.name}: ${message}`;
|
|
912
|
+
};
|
|
913
|
+
const networkErrorMessage = (method, target, attempts, cause) => {
|
|
914
|
+
const base = `Network error on ${method} ${target} after ${attempts === 1 ? "1 attempt" : `${attempts} attempts`}: ${failureDetail(cause)}`;
|
|
915
|
+
const note = classifyNetworkError(cause) === "pre-send" ? NEVER_SENT_NOTE : MAY_HAVE_APPLIED_NOTE;
|
|
916
|
+
return toAscii(method === "GET" ? base : `${base}.${writeNote(method, note)}`);
|
|
917
|
+
};
|
|
918
|
+
const createZendeskNetworkError = (method, target, attempts, cause) => Object.assign(new Error(networkErrorMessage(method, target, attempts, cause), { cause }), {
|
|
919
|
+
name: "ZendeskNetworkError",
|
|
920
|
+
method,
|
|
921
|
+
target,
|
|
922
|
+
attempts
|
|
923
|
+
});
|
|
924
|
+
/** Exponential backoff with equal jitter: half the window fixed, half random. */
|
|
925
|
+
const computeBackoffMs = (attempt, random) => {
|
|
926
|
+
const window = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
|
927
|
+
return Math.round(window / 2 + window * random() / 2);
|
|
928
|
+
};
|
|
929
|
+
/** `Retry-After` in ms — delay-seconds or HTTP-date. */
|
|
930
|
+
const parseRetryAfter = (header, now = Date.now()) => {
|
|
931
|
+
if (header === null) return void 0;
|
|
932
|
+
const value = header.trim();
|
|
933
|
+
if (DELAY_SECONDS.test(value)) return Number(value) * 1e3;
|
|
934
|
+
if (!HTTP_DATE_START.test(value)) return void 0;
|
|
935
|
+
const date = Date.parse(value);
|
|
936
|
+
if (Number.isNaN(date)) return void 0;
|
|
937
|
+
return Math.max(0, date - now);
|
|
938
|
+
};
|
|
939
|
+
/**
|
|
940
|
+
* How long to wait before replaying this response, or undefined to accept it.
|
|
941
|
+
* `Retry-After` wins over backoff wherever it appears — Zendesk sends it on a 503
|
|
942
|
+
* during maintenance as well as on a 429 — and a value past the cap means the
|
|
943
|
+
* response is surfaced rather than parking the call for that long.
|
|
944
|
+
*/
|
|
945
|
+
const retryDelayFor = (response, policy, attempt, random) => {
|
|
946
|
+
if (!(response.status === 429 || response.status >= 500 && policy.serverErrors)) return void 0;
|
|
947
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
948
|
+
if (retryAfter === void 0) return computeBackoffMs(attempt, random);
|
|
949
|
+
return retryAfter > MAX_RETRY_AFTER_MS ? void 0 : retryAfter;
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* Runs `attempt` until it succeeds, hits a terminal outcome, or spends the
|
|
953
|
+
* attempt budget. Returns the last response for the caller to inspect (a
|
|
954
|
+
* non-ok status is still the caller's to turn into a `ZendeskApiError`), and
|
|
955
|
+
* throws `ZendeskNetworkError` when no response was ever obtained.
|
|
956
|
+
*/
|
|
957
|
+
const fetchWithRetry = async (attempt, method, target, deps = defaultRetryDeps) => {
|
|
958
|
+
const policy = POLICIES[method];
|
|
959
|
+
for (let tries = 1;; tries += 1) {
|
|
960
|
+
const last = tries >= MAX_ATTEMPTS;
|
|
961
|
+
let response;
|
|
962
|
+
try {
|
|
963
|
+
response = await attempt();
|
|
964
|
+
} catch (err) {
|
|
965
|
+
const replayable = policy.network === "any" || classifyNetworkError(err) === policy.network;
|
|
966
|
+
if (last || !replayable) throw createZendeskNetworkError(method, target, tries, err);
|
|
967
|
+
await deps.sleep(computeBackoffMs(tries, deps.random));
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (last) return response;
|
|
971
|
+
const delayMs = retryDelayFor(response, policy, tries, deps.random);
|
|
972
|
+
if (delayMs === void 0) return response;
|
|
973
|
+
await response.text().catch(() => void 0);
|
|
974
|
+
await deps.sleep(delayMs);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
//#endregion
|
|
815
978
|
//#region src/client/zendesk-api.ts
|
|
816
979
|
var ZendeskApiError = class ZendeskApiError extends Error {
|
|
817
980
|
status;
|
|
818
981
|
statusText;
|
|
819
982
|
body;
|
|
820
|
-
|
|
821
|
-
|
|
983
|
+
method;
|
|
984
|
+
constructor(status, statusText, body, method) {
|
|
985
|
+
super(ZendeskApiError.buildMessage(status, statusText, body, method));
|
|
822
986
|
this.status = status;
|
|
823
987
|
this.statusText = statusText;
|
|
824
988
|
this.body = body;
|
|
989
|
+
this.method = method;
|
|
825
990
|
this.name = "ZendeskApiError";
|
|
826
991
|
}
|
|
827
|
-
static buildMessage(status, statusText, body) {
|
|
992
|
+
static buildMessage(status, statusText, body, method) {
|
|
828
993
|
switch (status) {
|
|
829
994
|
case 401: return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
|
|
830
995
|
case 403: return "Permission denied. Your Zendesk account does not have access to this resource.";
|
|
831
996
|
case 404: return `Resource not found. Please verify the ID is correct. (${statusText})`;
|
|
832
997
|
case 422: return `Validation error: ${body}`;
|
|
833
|
-
case 429: return
|
|
834
|
-
default:
|
|
998
|
+
case 429: return `Rate limit exceeded. Please wait before making more requests.${writeNote(method, REFUSED_NOTE)}`;
|
|
999
|
+
default: {
|
|
1000
|
+
const message = `Zendesk API error ${status}: ${statusText}. ${body}`;
|
|
1001
|
+
return status >= 500 ? `${message}${writeNote(method, MAY_HAVE_APPLIED_NOTE)}` : message;
|
|
1002
|
+
}
|
|
835
1003
|
}
|
|
836
1004
|
}
|
|
837
1005
|
};
|
|
@@ -841,6 +1009,11 @@ const buildUrl = (base, path, params) => {
|
|
|
841
1009
|
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
842
1010
|
return url.toString();
|
|
843
1011
|
};
|
|
1012
|
+
const performFetch = (method, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => fetchWithRetry(() => fetch(url, {
|
|
1013
|
+
...init,
|
|
1014
|
+
method,
|
|
1015
|
+
signal: deadlineSignal(timeoutMs)
|
|
1016
|
+
}), method, describeTarget(url));
|
|
844
1017
|
const executeRequest = async (url, token, options = {}) => {
|
|
845
1018
|
const { method = "GET", body } = options;
|
|
846
1019
|
const headers = {
|
|
@@ -848,15 +1021,12 @@ const executeRequest = async (url, token, options = {}) => {
|
|
|
848
1021
|
Accept: "application/json"
|
|
849
1022
|
};
|
|
850
1023
|
if (body) headers["Content-Type"] = "application/json";
|
|
851
|
-
const init = {
|
|
852
|
-
method,
|
|
853
|
-
headers
|
|
854
|
-
};
|
|
1024
|
+
const init = { headers };
|
|
855
1025
|
if (body) init.body = JSON.stringify(body);
|
|
856
|
-
const response = await
|
|
1026
|
+
const response = await performFetch(method, url, init);
|
|
857
1027
|
if (!response.ok) {
|
|
858
1028
|
const responseBody = await response.text();
|
|
859
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1029
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, method);
|
|
860
1030
|
}
|
|
861
1031
|
if (response.status === 204) return {};
|
|
862
1032
|
return response.json();
|
|
@@ -905,10 +1075,10 @@ const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
|
|
|
905
1075
|
const expectedHost = `${subdomain}.zendesk.com`;
|
|
906
1076
|
const headers = {};
|
|
907
1077
|
if (new URL(contentUrl).hostname === expectedHost) headers["Authorization"] = buildAuthHeader(token);
|
|
908
|
-
const response = await
|
|
1078
|
+
const response = await performFetch("GET", contentUrl, { headers }, TRANSFER_TIMEOUT_MS);
|
|
909
1079
|
if (!response.ok) {
|
|
910
1080
|
const body = await response.text();
|
|
911
|
-
throw new ZendeskApiError(response.status, response.statusText, body);
|
|
1081
|
+
throw new ZendeskApiError(response.status, response.statusText, body, "GET");
|
|
912
1082
|
}
|
|
913
1083
|
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
914
1084
|
const arrayBuffer = await response.arrayBuffer();
|
|
@@ -921,30 +1091,28 @@ const zendeskUpload = async (subdomain, token, filename, data, contentType, uplo
|
|
|
921
1091
|
const params = { filename };
|
|
922
1092
|
if (uploadToken) params["token"] = uploadToken;
|
|
923
1093
|
const url = buildUrl(getBaseUrl(subdomain), "/uploads", params);
|
|
924
|
-
const response = await
|
|
925
|
-
method: "POST",
|
|
1094
|
+
const response = await performFetch("POST", url, {
|
|
926
1095
|
headers: {
|
|
927
1096
|
Authorization: buildAuthHeader(token),
|
|
928
1097
|
"Content-Type": contentType
|
|
929
1098
|
},
|
|
930
1099
|
body: data
|
|
931
|
-
});
|
|
1100
|
+
}, TRANSFER_TIMEOUT_MS);
|
|
932
1101
|
if (!response.ok) {
|
|
933
1102
|
const responseBody = await response.text();
|
|
934
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1103
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
|
|
935
1104
|
}
|
|
936
1105
|
return response.json();
|
|
937
1106
|
};
|
|
938
1107
|
const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
939
1108
|
const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
|
|
940
|
-
const response = await
|
|
941
|
-
method: "POST",
|
|
1109
|
+
const response = await performFetch("POST", url, {
|
|
942
1110
|
headers: { Authorization: buildAuthHeader(token) },
|
|
943
1111
|
body: formData
|
|
944
|
-
});
|
|
1112
|
+
}, TRANSFER_TIMEOUT_MS);
|
|
945
1113
|
if (!response.ok) {
|
|
946
1114
|
const responseBody = await response.text();
|
|
947
|
-
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
1115
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
|
|
948
1116
|
}
|
|
949
1117
|
return response.json();
|
|
950
1118
|
};
|
|
@@ -4375,6 +4543,10 @@ const registerReloadTool = (server, reload, logger = silentLogger) => {
|
|
|
4375
4543
|
* `reload_tools` tool that hot-reloads edited tool code on demand. stdio only —
|
|
4376
4544
|
* HTTP builds a per-session server per request, so there is no long-lived
|
|
4377
4545
|
* server to hot-swap.
|
|
4546
|
+
*
|
|
4547
|
+
* Returns the running server so the caller can close it on shutdown: dev mode
|
|
4548
|
+
* runs over the same stdio transport as normal mode and must exit the same way
|
|
4549
|
+
* when the client disconnects.
|
|
4378
4550
|
*/
|
|
4379
4551
|
/* v8 ignore start -- runtime bootstrap: binds the reload tool to a real stdio
|
|
4380
4552
|
transport; the reload machinery it wires up is covered by dev-reload.test.ts */
|
|
@@ -4383,6 +4555,7 @@ const startDevServer = async (config, getToken, logger = silentLogger, onUnautho
|
|
|
4383
4555
|
registerReloadTool(server, reload, logger);
|
|
4384
4556
|
await startStdioTransport(server, logger);
|
|
4385
4557
|
logger.info("dev_mode_enabled");
|
|
4558
|
+
return server;
|
|
4386
4559
|
};
|
|
4387
4560
|
/* v8 ignore stop */
|
|
4388
4561
|
//#endregion
|
|
@@ -4593,8 +4766,10 @@ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
|
|
|
4593
4766
|
const respondBodyError = (req, res, failure) => {
|
|
4594
4767
|
const headers = failure.status === 413 ? { Connection: "close" } : {};
|
|
4595
4768
|
sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
|
|
4596
|
-
if (failure.status === 413)
|
|
4597
|
-
|
|
4769
|
+
if (failure.status === 413) {
|
|
4770
|
+
if (res.writableFinished) req.destroy();
|
|
4771
|
+
else res.once("finish", () => req.destroy());
|
|
4772
|
+
}
|
|
4598
4773
|
};
|
|
4599
4774
|
const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
4600
4775
|
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
@@ -4736,27 +4911,125 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4736
4911
|
};
|
|
4737
4912
|
};
|
|
4738
4913
|
//#endregion
|
|
4914
|
+
//#region src/utils/shutdown.ts
|
|
4915
|
+
/**
|
|
4916
|
+
* How long a shutdown may take before the watchdog forces the exit.
|
|
4917
|
+
*
|
|
4918
|
+
* Generous enough for `server.close()` and an HTTP session drain, short enough
|
|
4919
|
+
* to stay inside the tightest common supervisor grace — `docker stop` allows 10s
|
|
4920
|
+
* and Kubernetes 30s before their own SIGKILL (systemd is far laxer at 90s). A
|
|
4921
|
+
* process killed by its supervisor is exactly the unclean exit this removes.
|
|
4922
|
+
*/
|
|
4923
|
+
const SHUTDOWN_GRACE_MS = 3e3;
|
|
4924
|
+
/**
|
|
4925
|
+
* Adapt a `process` to a {@link ShutdownRuntime}. Takes the process as an
|
|
4926
|
+
* argument rather than closing over the global so the adapter itself is
|
|
4927
|
+
* testable — otherwise the one part of this module that touches the real
|
|
4928
|
+
* process would be the one part no test can reach.
|
|
4929
|
+
*/
|
|
4930
|
+
const createRuntime = (proc) => ({
|
|
4931
|
+
on: (event, listener) => {
|
|
4932
|
+
proc.on(event, listener);
|
|
4933
|
+
},
|
|
4934
|
+
stdin: { on: (event, listener) => {
|
|
4935
|
+
proc.stdin.on(event, listener);
|
|
4936
|
+
} },
|
|
4937
|
+
exit: (code) => {
|
|
4938
|
+
proc.exit(code);
|
|
4939
|
+
},
|
|
4940
|
+
setTimer: (fn, ms) => {
|
|
4941
|
+
const timer = setTimeout(fn, ms);
|
|
4942
|
+
return {
|
|
4943
|
+
unref: () => void timer.unref(),
|
|
4944
|
+
clear: () => clearTimeout(timer)
|
|
4945
|
+
};
|
|
4946
|
+
}
|
|
4947
|
+
});
|
|
4948
|
+
const defaultRuntime = createRuntime(process);
|
|
4949
|
+
/**
|
|
4950
|
+
* Install the process's one shutdown path and return its trigger.
|
|
4951
|
+
*
|
|
4952
|
+
* Registering a `SIGTERM` handler *removes* Node's default terminate, which
|
|
4953
|
+
* makes the exit our responsibility: a cleanup that stalls on an in-flight
|
|
4954
|
+
* request would otherwise leave a process SIGTERM cannot kill — the very
|
|
4955
|
+
* symptom this exists to remove. Hence the watchdog, which is load-bearing
|
|
4956
|
+
* rather than defensive, and the unconditional `exit` on every path.
|
|
4957
|
+
*
|
|
4958
|
+
* The exit is explicit rather than a drained event loop because the OAuth
|
|
4959
|
+
* callback server (`auth/browser-oauth.ts`) is a listening socket that is not
|
|
4960
|
+
* `unref()`'d: letting the loop drain would keep a disconnected session alive
|
|
4961
|
+
* for up to the 5-minute auth timeout.
|
|
4962
|
+
*/
|
|
4963
|
+
const installShutdown = (options) => {
|
|
4964
|
+
const { cleanup, logger, watchStdin, graceMs = SHUTDOWN_GRACE_MS } = options;
|
|
4965
|
+
const runtime = options.runtime ?? defaultRuntime;
|
|
4966
|
+
let started = false;
|
|
4967
|
+
let exited = false;
|
|
4968
|
+
const exitOnce = (code) => {
|
|
4969
|
+
if (exited) return;
|
|
4970
|
+
exited = true;
|
|
4971
|
+
runtime.exit(code);
|
|
4972
|
+
};
|
|
4973
|
+
const shutdown = async (reason) => {
|
|
4974
|
+
if (started) return;
|
|
4975
|
+
started = true;
|
|
4976
|
+
logger.info("shutdown_started", { reason });
|
|
4977
|
+
const watchdog = runtime.setTimer(() => {
|
|
4978
|
+
logger.warn("shutdown_forced", { graceMs });
|
|
4979
|
+
exitOnce(0);
|
|
4980
|
+
}, graceMs);
|
|
4981
|
+
watchdog.unref();
|
|
4982
|
+
try {
|
|
4983
|
+
await cleanup();
|
|
4984
|
+
logger.info("shutdown_complete", { reason });
|
|
4985
|
+
} catch (err) {
|
|
4986
|
+
logger.warn("shutdown_cleanup_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
4987
|
+
} finally {
|
|
4988
|
+
watchdog.clear();
|
|
4989
|
+
exitOnce(0);
|
|
4990
|
+
}
|
|
4991
|
+
};
|
|
4992
|
+
runtime.on("SIGINT", () => void shutdown("SIGINT"));
|
|
4993
|
+
runtime.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
4994
|
+
if (watchStdin) runtime.stdin.on("end", () => void shutdown("stdin_eof"));
|
|
4995
|
+
return shutdown;
|
|
4996
|
+
};
|
|
4997
|
+
//#endregion
|
|
4739
4998
|
//#region src/index.ts
|
|
4740
4999
|
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
4741
5000
|
subdomain: config.subdomain,
|
|
4742
5001
|
oauthClientId: config.oauthClientId,
|
|
4743
5002
|
callbackPort: config.callbackPort
|
|
4744
5003
|
}, logger);
|
|
5004
|
+
const connectStdio = async (config, tokenStore, logger) => {
|
|
5005
|
+
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
5006
|
+
const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
5007
|
+
await startStdioTransport(server, logger);
|
|
5008
|
+
return server;
|
|
5009
|
+
};
|
|
4745
5010
|
const main = async () => {
|
|
4746
5011
|
const config = loadConfig();
|
|
4747
5012
|
const logger = createLogger(config.logLevel);
|
|
4748
5013
|
if (config.transport === "stdio") {
|
|
4749
5014
|
const tokenStore = buildStdioTokenStore(config, logger);
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
5015
|
+
const server = await connectStdio(config, tokenStore, logger);
|
|
5016
|
+
installShutdown({
|
|
5017
|
+
watchStdin: true,
|
|
5018
|
+
logger,
|
|
5019
|
+
cleanup: async () => {
|
|
5020
|
+
await server.close();
|
|
5021
|
+
tokenStore.dispose();
|
|
5022
|
+
}
|
|
5023
|
+
});
|
|
4756
5024
|
return;
|
|
4757
5025
|
}
|
|
4758
5026
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
4759
|
-
await startHttpTransport(config, logger);
|
|
5027
|
+
const http = await startHttpTransport(config, logger);
|
|
5028
|
+
installShutdown({
|
|
5029
|
+
watchStdin: false,
|
|
5030
|
+
logger,
|
|
5031
|
+
cleanup: http.close
|
|
5032
|
+
});
|
|
4760
5033
|
};
|
|
4761
5034
|
main().catch((error) => {
|
|
4762
5035
|
console.error("Fatal error:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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,13 +69,13 @@
|
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=20"
|
|
71
71
|
},
|
|
72
|
-
"packageManager": "pnpm@11.
|
|
72
|
+
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
75
75
|
"cheerio": "1.2.0",
|
|
76
76
|
"hast-util-to-html": "9.0.5",
|
|
77
77
|
"hast-util-to-mdast": "10.1.2",
|
|
78
|
-
"open": "11.0.
|
|
78
|
+
"open": "11.0.1",
|
|
79
79
|
"rehype-parse": "9.0.1",
|
|
80
80
|
"rehype-raw": "7.0.0",
|
|
81
81
|
"rehype-remark": "10.0.1",
|
|
@@ -88,15 +88,15 @@
|
|
|
88
88
|
"zod": "4.4.3"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
|
-
"@biomejs/biome": "2.5.
|
|
92
|
-
"@semantic-release/changelog": "^
|
|
91
|
+
"@biomejs/biome": "2.5.8",
|
|
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",
|
|
98
|
-
"@stryker-mutator/core": "^
|
|
99
|
-
"@stryker-mutator/vitest-runner": "^
|
|
98
|
+
"@stryker-mutator/core": "^10.0.0",
|
|
99
|
+
"@stryker-mutator/vitest-runner": "^10.0.0",
|
|
100
100
|
"@tsconfig/node20": "^20.1.9",
|
|
101
101
|
"@tsconfig/strictest": "^2.0.8",
|
|
102
102
|
"@types/hast": "^3.0.4",
|