@fruggr/zendesk-mcp-server 2.22.2 → 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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +37 -16
  3. 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 broader token stays usable and two servers
180
- * sharing a token file converge. A non-string `granted` is a pre-#283 record,
181
- * i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
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; this spells out the
203
- * fix (set a free port + register the matching redirect URL in Zendesk). The
204
- * `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
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 by another process. 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)`), {
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 = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
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 a subdomain. Each subdomain gets its **own** file
489
- * (`<subdomain>.json`, a single record) so concurrent processes for different
490
- * subdomains never read-modify-write a shared file — no merge, no clobber.
491
- * `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
492
- * default layout for multi-subdomain installs).
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 = (subdomain) => {
506
+ const resolveTokenPath = (key) => {
495
507
  const override = process.env["ZENDESK_TOKEN_FILE"];
496
508
  if (override) return override;
497
- return join(configDir(), `${safeName(subdomain)}.json`);
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.22.2",
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",