@fruggr/zendesk-mcp-server 2.22.1 → 2.22.3
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 +2 -0
- package/dist/index.js +56 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,6 +152,8 @@ waiting for sign-in, so authenticate in the browser and then retry the request.
|
|
|
152
152
|
The token is persisted to an owner-only file and reused across restarts, so you
|
|
153
153
|
don't authenticate again every time your MCP client respawns the server (path
|
|
154
154
|
and overrides: [`ZENDESK_TOKEN_FILE`](docs/configuration.md#zendesk_token_file)).
|
|
155
|
+
Several instances can run side by side — a read-write one and a `--read-only`
|
|
156
|
+
one, say — without trading credentials.
|
|
155
157
|
|
|
156
158
|
### MCP client wiring
|
|
157
159
|
|
package/dist/index.js
CHANGED
|
@@ -176,9 +176,11 @@ const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
|
|
|
176
176
|
const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
|
|
177
177
|
/**
|
|
178
178
|
* Whether a grant still covers what this process needs: a flat subset test.
|
|
179
|
-
* Coverage, not equality, so a
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* Coverage, not equality, so a token Zendesk granted wider than requested stays
|
|
180
|
+
* usable. A non-string `granted` is a pre-#283 record, i.e. `read write` — only
|
|
181
|
+
* reachable now through a `ZENDESK_TOKEN_FILE` aimed at one by hand, since the
|
|
182
|
+
* default layout no longer names those files. No scope hierarchy: granular
|
|
183
|
+
* scopes (#284) replace this.
|
|
182
184
|
*/
|
|
183
185
|
const grantCovers = (granted, requested) => {
|
|
184
186
|
if (typeof granted !== "string") return true;
|
|
@@ -199,11 +201,13 @@ const detectWsl = () => {
|
|
|
199
201
|
};
|
|
200
202
|
/**
|
|
201
203
|
* Build an actionable error for a callback port that's already taken. The raw
|
|
202
|
-
* Node `EADDRINUSE` is opaque to both the user and the LLM
|
|
203
|
-
*
|
|
204
|
-
*
|
|
204
|
+
* Node `EADDRINUSE` is opaque to both the user and the LLM. The port is also
|
|
205
|
+
* the mutex between instances, so retrying once the other sign-in ends comes
|
|
206
|
+
* first and the free-port escape hatch second
|
|
207
|
+
* (`docs/decisions/token-file-keying.md`). The `(EADDRINUSE)` marker and `code`
|
|
208
|
+
* are kept for diagnostics/tests.
|
|
205
209
|
*/
|
|
206
|
-
const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use
|
|
210
|
+
const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use. Another instance of this server is most likely signing in right now: finish that browser window, then retry. If an unrelated program holds the port, set ZENDESK_OAUTH_CALLBACK_PORT (or --callback-port) to a free port, then register http://localhost:<port>/callback as a redirect URL in your Zendesk OAuth client. (EADDRINUSE)`), {
|
|
207
211
|
code: "EADDRINUSE",
|
|
208
212
|
cause
|
|
209
213
|
});
|
|
@@ -483,18 +487,31 @@ const configDir = () => {
|
|
|
483
487
|
const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
484
488
|
return join(base, ...segments);
|
|
485
489
|
};
|
|
486
|
-
const safeName = (
|
|
490
|
+
const safeName = (part) => part.replace(/[^a-z0-9-]/gi, "_");
|
|
491
|
+
const READABLE_BUDGET = 120;
|
|
492
|
+
const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
|
|
493
|
+
key.subdomain,
|
|
494
|
+
key.oauthClientId,
|
|
495
|
+
key.scope
|
|
496
|
+
])).digest("hex").slice(0, 8);
|
|
487
497
|
/**
|
|
488
|
-
* Path to the token file for
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
498
|
+
* Path to the token file for one credential. Keyed on the whole `TokenKey`, so
|
|
499
|
+
* a read-only server, a read-write one and one on another OAuth client each get
|
|
500
|
+
* their own record instead of clobbering a shared file. Why that triple, and
|
|
501
|
+
* why no migration from the old subdomain-only layout:
|
|
502
|
+
* `docs/decisions/token-file-keying.md`.
|
|
503
|
+
* `ZENDESK_TOKEN_FILE` overrides with an explicit path — the way to separate
|
|
504
|
+
* two Zendesk accounts that share a subdomain, client and scope.
|
|
493
505
|
*/
|
|
494
|
-
const resolveTokenPath = (
|
|
506
|
+
const resolveTokenPath = (key) => {
|
|
495
507
|
const override = process.env["ZENDESK_TOKEN_FILE"];
|
|
496
508
|
if (override) return override;
|
|
497
|
-
|
|
509
|
+
const readable = [
|
|
510
|
+
key.subdomain,
|
|
511
|
+
key.oauthClientId,
|
|
512
|
+
key.scope
|
|
513
|
+
].map(safeName).join("--").slice(0, READABLE_BUDGET);
|
|
514
|
+
return join(configDir(), `${readable}--${keyDigest(key)}.json`);
|
|
498
515
|
};
|
|
499
516
|
const writeFileAtomic = (path, record) => {
|
|
500
517
|
const dir = dirname(path);
|
|
@@ -539,8 +556,12 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
|
|
|
539
556
|
});
|
|
540
557
|
const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
|
|
541
558
|
const createTokenStore = (config, logger = silentLogger) => {
|
|
542
|
-
const tokenPath = resolveTokenPath(config.subdomain);
|
|
543
559
|
const requested = requestedScope(config.readOnly);
|
|
560
|
+
const tokenPath = resolveTokenPath({
|
|
561
|
+
subdomain: config.subdomain,
|
|
562
|
+
oauthClientId: config.oauthClientId,
|
|
563
|
+
scope: requested
|
|
564
|
+
});
|
|
544
565
|
let token = loadToken(tokenPath);
|
|
545
566
|
if (token) {
|
|
546
567
|
if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
|
|
@@ -1388,17 +1409,24 @@ const formatMacro = (macro) => {
|
|
|
1388
1409
|
...actions.map(formatMacroAction)
|
|
1389
1410
|
].filter(Boolean).join("\n");
|
|
1390
1411
|
};
|
|
1391
|
-
const minutesUntil = (iso) => {
|
|
1412
|
+
const minutesUntil = (iso, now) => {
|
|
1392
1413
|
const t = Date.parse(iso);
|
|
1393
|
-
return Number.isNaN(t) ? null : Math.round((t -
|
|
1414
|
+
return Number.isNaN(t) ? null : Math.round((t - now) / 6e4);
|
|
1394
1415
|
};
|
|
1395
|
-
const
|
|
1416
|
+
const STAGES_WITHOUT_LIVE_DEADLINE = /* @__PURE__ */ new Set([
|
|
1417
|
+
"paused",
|
|
1418
|
+
"achieved",
|
|
1419
|
+
"fulfilled"
|
|
1420
|
+
]);
|
|
1421
|
+
const hasRunningStage = (m) => !STAGES_WITHOUT_LIVE_DEADLINE.has(m.stage);
|
|
1422
|
+
const hasPendingDeadline = (m) => hasRunningStage(m) && typeof m.breach_at === "string";
|
|
1423
|
+
const formatSlaMetric = (m, now) => {
|
|
1396
1424
|
const stage = m.stage ?? "unknown";
|
|
1397
1425
|
const due = m.breach_at ?? null;
|
|
1398
1426
|
const parts = [`- **${m.metric}** — ${stage}`];
|
|
1399
1427
|
if (due) {
|
|
1400
|
-
const remaining = minutesUntil(due);
|
|
1401
|
-
if (
|
|
1428
|
+
const remaining = minutesUntil(due, now);
|
|
1429
|
+
if (!hasRunningStage(m) || remaining == null) parts.push(`due ${due}`);
|
|
1402
1430
|
else if (remaining < 0) parts.push(`due ${due} — breached (${Math.abs(remaining)} min overdue)`);
|
|
1403
1431
|
else parts.push(`due ${due} — ${remaining} min remaining`);
|
|
1404
1432
|
}
|
|
@@ -1406,10 +1434,14 @@ const formatSlaMetric = (m) => {
|
|
|
1406
1434
|
};
|
|
1407
1435
|
const formatSlaBlock = (entry) => {
|
|
1408
1436
|
if (!entry?.policy_metrics || entry.policy_metrics.length === 0) return "";
|
|
1437
|
+
const now = Date.now();
|
|
1409
1438
|
const lines = ["### SLA"];
|
|
1410
|
-
const
|
|
1411
|
-
|
|
1412
|
-
|
|
1439
|
+
const soonest = entry.policy_metrics.filter(hasPendingDeadline).map((m) => ({
|
|
1440
|
+
due: m.breach_at,
|
|
1441
|
+
at: Date.parse(m.breach_at)
|
|
1442
|
+
})).filter((d) => !Number.isNaN(d.at) && d.at > now).reduce((best, d) => best && best.at <= d.at ? best : d, void 0);
|
|
1443
|
+
if (soonest) lines.push(`- **Next breach**: ${soonest.due}`);
|
|
1444
|
+
for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m, now));
|
|
1413
1445
|
return `\n\n${lines.join("\n")}`;
|
|
1414
1446
|
};
|
|
1415
1447
|
const withName = (id, names) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.3",
|
|
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",
|