@fruggr/zendesk-mcp-server 1.6.0 → 1.8.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/README.md CHANGED
@@ -190,7 +190,9 @@ No API key needed. Each user authenticates via their browser on the first tool c
190
190
  1. Go to **Admin Center > Apps and integrations > APIs > OAuth Clients**
191
191
  2. Create a public client:
192
192
  - **Identifier**: `<your-subdomain>_zendesk` (or set `ZENDESK_OAUTH_CLIENT_ID`)
193
- - **Redirect URL**: `http://localhost:3000/callback`
193
+ - **Redirect URL**: `http://localhost:27439/callback` (change the port to match
194
+ `ZENDESK_OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
195
+ accepts several redirect URLs, one per line)
194
196
 
195
197
  **Run:**
196
198
 
@@ -201,8 +203,22 @@ zendesk-mcp-server <your-subdomain>
201
203
  On the first tool call, the server starts the sign-in flow: it opens a browser
202
204
  window **and** returns a tool message containing the authorize URL. The call
203
205
  does not block waiting for sign-in — authenticate in the browser (or open the
204
- URL manually if it didn't open), then retry the request. Once authenticated, the
205
- token is cached in memory and subsequent calls succeed for the session.
206
+ URL manually if it didn't open), then retry the request.
207
+
208
+ Once authenticated, the token is **persisted to disk** (one owner-only `0600`
209
+ file per subdomain in your OS config dir —
210
+ `%APPDATA%\fruggr\zendesk-mcp-server\<subdomain>.json` on Windows,
211
+ `${XDG_CONFIG_HOME:-~/.config}/fruggr/zendesk-mcp-server/<subdomain>.json`
212
+ elsewhere; override the path with `ZENDESK_TOKEN_FILE`). It is reused across restarts, so you don't
213
+ re-authenticate every time the MCP client respawns the server. If the Zendesk
214
+ OAuth client has token expiration enabled, the stored refresh token is used to
215
+ renew access silently; only an expired/invalid refresh token triggers a new
216
+ browser sign-in.
217
+
218
+ > **Port conflict?** If port `27439` is already in use the first tool call returns
219
+ > a clear error telling you to set `ZENDESK_OAUTH_CALLBACK_PORT` (or
220
+ > `--callback-port`) to a free port — remember to register the matching
221
+ > `http://localhost:<port>/callback` redirect URL in your Zendesk OAuth client.
206
222
 
207
223
  ### Option B: API token
208
224
 
@@ -290,6 +306,7 @@ Options:
290
306
  --tool <name> Filter by tool name (repeatable, forces --mode all)
291
307
  --read-only Only expose read operations
292
308
  --log-level <level> debug | info (default) | warn | error
309
+ --callback-port <port> Local OAuth callback port (default 27439)
293
310
  ```
294
311
 
295
312
  `--namespace` and `--read-only` are applied before the proxies are registered, so they narrow the surface in every mode — in the default `namespace` mode, `--namespace help_center` registers a single proxy (`zendesk_help_center`) instead of three.
@@ -313,6 +330,8 @@ zendesk-mcp-server acme --tool get_ticket --tool search_tickets --tool get_curre
313
330
  |----------|----------|---------|-------------|
314
331
  | `ZENDESK_SUBDOMAIN` | yes (or CLI arg) | — | Zendesk subdomain (e.g., `acme` for acme.zendesk.com) |
315
332
  | `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
333
+ | `ZENDESK_OAUTH_CALLBACK_PORT` | no | `27439` | Local port for the OAuth browser callback (also `--callback-port`). Must match the redirect URL registered in Zendesk. |
334
+ | `ZENDESK_TOKEN_FILE` | no | OS config dir | Path to the persisted OAuth token file (`0600`). |
316
335
  | `ZENDESK_EMAIL` | for API token auth | — | Agent email for Basic auth |
317
336
  | `ZENDESK_API_TOKEN` | for API token auth | — | Zendesk API token |
318
337
  | `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
@@ -340,6 +359,22 @@ When the browser fails to open, look for the `oauth_browser_open_failed` event:
340
359
  it reports the underlying error, the platform, and which environment markers are
341
360
  present (no secrets, tokens, or env values are ever logged).
342
361
 
362
+ ### The OAuth callback port is already in use
363
+
364
+ The sign-in flow runs a short-lived local server on port `27439` to receive the
365
+ callback. If that port is taken, the first tool call fails with a message saying
366
+ so (and logs `oauth_callback_listen_failed`). Pick a free port with
367
+ `ZENDESK_OAUTH_CALLBACK_PORT=<port>` (or `--callback-port <port>`), and register
368
+ the matching `http://localhost:<port>/callback` redirect URL in your Zendesk
369
+ OAuth client.
370
+
371
+ ### I have to re-authenticate every time
372
+
373
+ The OAuth token is persisted to an owner-only file in your OS config dir and
374
+ reused across restarts, so this shouldn't happen. If it does, check that the file
375
+ is writable (`ZENDESK_TOKEN_FILE` to relocate it) and look for
376
+ `token_persist_failed` in the logs.
377
+
343
378
  Where each client writes the server's stderr:
344
379
 
345
380
  | Client | Log location |
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { readFileSync } from "node:fs";
3
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
- import { release } from "node:os";
5
+ import { homedir, release } from "node:os";
6
6
  import open from "open";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
  import * as z from "zod/v4";
8
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
11
  import * as cheerio from "cheerio";
@@ -17,8 +19,6 @@ import remarkParse from "remark-parse";
17
19
  import remarkRehype from "remark-rehype";
18
20
  import remarkStringify from "remark-stringify";
19
21
  import { unified } from "unified";
20
- import { dirname, join } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
23
  //#region src/auth/api-token.ts
24
24
  /**
@@ -126,7 +126,6 @@ const getOAuthUrls = (subdomain) => ({
126
126
  });
127
127
  //#endregion
128
128
  //#region src/auth/browser-oauth.ts
129
- const DEFAULT_CALLBACK_PORT = 3e3;
130
129
  const AUTH_TIMEOUT_MS = 300 * 1e3;
131
130
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
132
131
  const detectWsl = () => {
@@ -138,6 +137,16 @@ const detectWsl = () => {
138
137
  }
139
138
  };
140
139
  /**
140
+ * Build an actionable error for a callback port that's already taken. The raw
141
+ * Node `EADDRINUSE` is opaque to both the user and the LLM; this spells out the
142
+ * fix (set a free port + register the matching redirect URL in Zendesk). The
143
+ * `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
144
+ */
145
+ 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)`), {
146
+ code: "EADDRINUSE",
147
+ cause
148
+ });
149
+ /**
141
150
  * Escape a string for safe interpolation into HTML text/attribute context.
142
151
  * The local callback server echoes attacker-controllable values (the OAuth
143
152
  * `error_description` query param, token-exchange error bodies) back into the
@@ -221,7 +230,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
221
230
  const tokenData = await tokenResponse.json();
222
231
  logger.info("oauth_authenticated");
223
232
  res.writeHead(200, { "Content-Type": "text/html" });
224
- res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><script>window.close()<\/script></body></html>");
233
+ res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
225
234
  clearTimeout(authTimeout);
226
235
  callbackServer.close();
227
236
  resolveToken(tokenData);
@@ -233,12 +242,18 @@ const startBrowserAuth = (config, logger = silentLogger) => {
233
242
  rejectToken(err);
234
243
  }
235
244
  });
245
+ const requestedPort = config.callbackPort ?? 27439;
236
246
  const onStartError = (err) => {
237
247
  clearTimeout(authTimeout);
238
- rejectStarted(err);
248
+ const code = err.code;
249
+ logger.error("oauth_callback_listen_failed", {
250
+ port: requestedPort,
251
+ errorCode: code
252
+ });
253
+ rejectStarted(code === "EADDRINUSE" ? callbackPortInUseError(requestedPort, err) : err);
239
254
  };
240
255
  callbackServer.once("error", onStartError);
241
- callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
256
+ callbackServer.listen(requestedPort, () => {
242
257
  callbackServer.off("error", onStartError);
243
258
  callbackServer.once("error", (err) => {
244
259
  clearTimeout(authTimeout);
@@ -292,34 +307,199 @@ const startBrowserAuth = (config, logger = silentLogger) => {
292
307
  });
293
308
  });
294
309
  };
310
+ /**
311
+ * Exchange a refresh token for a fresh access token (and a rotated refresh token)
312
+ * without any browser interaction. Public PKCE clients send no `client_secret`.
313
+ * Zendesk refresh tokens are single-use: the caller MUST persist the new
314
+ * `refresh_token` from the response. Throws on a non-2xx (expired/invalid
315
+ * refresh token) so the caller can fall back to the full browser flow.
316
+ */
317
+ const refreshAccessToken = async (config, logger = silentLogger) => {
318
+ const { tokenUrl } = getOAuthUrls(config.subdomain);
319
+ const body = new URLSearchParams({
320
+ grant_type: "refresh_token",
321
+ refresh_token: config.refreshToken,
322
+ client_id: config.oauthClientId
323
+ });
324
+ const response = await fetch(tokenUrl, {
325
+ method: "POST",
326
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
327
+ body: body.toString()
328
+ });
329
+ logger.debug("oauth_token_refresh", { status: response.status });
330
+ if (!response.ok) {
331
+ const errorBody = await response.text();
332
+ throw new Error(`Token refresh failed (${response.status}): ${errorBody}`);
333
+ }
334
+ const tokenData = await response.json();
335
+ logger.info("oauth_token_refreshed");
336
+ return tokenData;
337
+ };
338
+ //#endregion
339
+ //#region src/utils/package-info.ts
340
+ const FALLBACK = {
341
+ name: "@fruggr/zendesk-mcp-server",
342
+ version: "0.0.0"
343
+ };
344
+ /**
345
+ * Read `name`/`version` from the package's own package.json at runtime instead
346
+ * of hardcoding them. Walks up from this module to the nearest package.json,
347
+ * which resolves correctly both when bundled (`dist/index.js` → repo root) and
348
+ * from source/tests (`src/` has no package.json, so the root is found). Reading
349
+ * at runtime (not inlining at build) matters because semantic-release bumps the
350
+ * version into package.json before publishing, after the build step.
351
+ */
352
+ let cached;
353
+ const readPackageInfo = () => {
354
+ if (cached) return cached;
355
+ let dir = dirname(fileURLToPath(import.meta.url));
356
+ for (let depth = 0; depth < 8; depth++) {
357
+ try {
358
+ const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
359
+ if (raw && typeof raw === "object") {
360
+ const pkg = raw;
361
+ if (typeof pkg.name === "string" && typeof pkg.version === "string") {
362
+ cached = {
363
+ name: pkg.name,
364
+ version: pkg.version
365
+ };
366
+ return cached;
367
+ }
368
+ }
369
+ } catch {}
370
+ const parent = dirname(dir);
371
+ if (parent === dir) break;
372
+ dir = parent;
373
+ }
374
+ cached = FALLBACK;
375
+ return cached;
376
+ };
377
+ //#endregion
378
+ //#region src/auth/token-persistence.ts
379
+ const isWindows = process.platform === "win32";
380
+ /**
381
+ * Config-dir segments derived from the *scoped* package name
382
+ * (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
383
+ * vendor-namespaced and can't collide with another `zendesk-mcp-server`.
384
+ */
385
+ const appDirSegments = () => {
386
+ const { name } = readPackageInfo();
387
+ const scoped = /^@([^/]+)\/(.+)$/.exec(name);
388
+ return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
389
+ };
390
+ const configDir = () => {
391
+ const segments = appDirSegments();
392
+ if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
393
+ return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
394
+ };
395
+ const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
396
+ /**
397
+ * Path to the token file for a subdomain. Each subdomain gets its **own** file
398
+ * (`<subdomain>.json`, a single record) so concurrent processes for different
399
+ * subdomains never read-modify-write a shared file — no merge, no clobber.
400
+ * `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
401
+ * default layout for multi-subdomain installs).
402
+ */
403
+ const resolveTokenPath = (subdomain) => {
404
+ const override = process.env["ZENDESK_TOKEN_FILE"];
405
+ if (override) return override;
406
+ return join(configDir(), `${safeName(subdomain)}.json`);
407
+ };
408
+ const writeFileAtomic = (path, record) => {
409
+ const dir = dirname(path);
410
+ mkdirSync(dir, { recursive: true });
411
+ const tmp = `${path}.${process.pid}.tmp`;
412
+ writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
413
+ if (!isWindows) chmodSync(tmp, 384);
414
+ renameSync(tmp, path);
415
+ if (!isWindows) try {
416
+ chmodSync(dir, 448);
417
+ } catch {}
418
+ };
419
+ const loadToken = (path) => {
420
+ try {
421
+ const raw = JSON.parse(readFileSync(path, "utf8"));
422
+ if (raw && typeof raw === "object" && typeof raw.accessToken === "string") return raw;
423
+ } catch {}
424
+ };
425
+ const saveToken = (path, record, logger = silentLogger) => {
426
+ try {
427
+ writeFileAtomic(path, record);
428
+ logger.debug("token_persisted");
429
+ } catch (err) {
430
+ logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
431
+ }
432
+ };
433
+ const clearToken = (path, logger = silentLogger) => {
434
+ try {
435
+ rmSync(path, { force: true });
436
+ logger.debug("token_cleared");
437
+ } catch (err) {
438
+ logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
439
+ }
440
+ };
295
441
  //#endregion
296
442
  //#region src/auth/token-store.ts
443
+ const EXPIRY_SKEW_MS = 6e4;
297
444
  const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
298
445
  name: "AuthRequiredError",
299
446
  authorizeUrl
300
447
  });
448
+ const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
301
449
  const createTokenStore = (config, logger = silentLogger) => {
302
- let token;
450
+ const tokenPath = resolveTokenPath(config.subdomain);
451
+ let token = loadToken(tokenPath);
452
+ if (token) logger.debug("oauth_token_loaded_from_disk");
303
453
  let authorizeUrl;
304
454
  let starting;
455
+ let refreshing;
456
+ const persist = (t) => saveToken(tokenPath, t, logger);
305
457
  const setToken = (accessToken, refreshToken) => {
306
458
  token = {
307
459
  accessToken,
308
460
  refreshToken
309
461
  };
462
+ persist(token);
463
+ };
464
+ const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
465
+ const tryRefresh = async (current) => {
466
+ if (!current.refreshToken) return void 0;
467
+ try {
468
+ const result = await refreshAccessToken({
469
+ subdomain: config.subdomain,
470
+ oauthClientId: config.oauthClientId,
471
+ refreshToken: current.refreshToken
472
+ }, logger);
473
+ token = {
474
+ accessToken: result.access_token,
475
+ refreshToken: result.refresh_token ?? current.refreshToken,
476
+ expiresAt: expiryFrom(result.expires_in)
477
+ };
478
+ persist(token);
479
+ logger.info("oauth_token_refreshed_cached");
480
+ return token.accessToken;
481
+ } catch (err) {
482
+ logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
483
+ token = void 0;
484
+ clearToken(tokenPath, logger);
485
+ return;
486
+ }
310
487
  };
311
488
  const beginAuth = () => {
312
489
  logger.info("oauth_auth_start");
313
490
  return startBrowserAuth({
314
491
  subdomain: config.subdomain,
315
- oauthClientId: config.oauthClientId
492
+ oauthClientId: config.oauthClientId,
493
+ callbackPort: config.callbackPort
316
494
  }, logger).then((started) => {
317
495
  authorizeUrl = started.authorizeUrl;
318
496
  started.tokenPromise.then((result) => {
319
497
  token = {
320
498
  accessToken: result.access_token,
321
- refreshToken: result.refresh_token
499
+ refreshToken: result.refresh_token,
500
+ expiresAt: expiryFrom(result.expires_in)
322
501
  };
502
+ persist(token);
323
503
  logger.info("oauth_token_cached");
324
504
  }).catch((err) => {
325
505
  logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
@@ -334,16 +514,38 @@ const createTokenStore = (config, logger = silentLogger) => {
334
514
  });
335
515
  };
336
516
  const getToken = async () => {
337
- if (token) {
517
+ if (token && !isExpired(token)) {
338
518
  logger.debug("oauth_token_cache_hit");
339
519
  return token.accessToken;
340
520
  }
521
+ if (token?.refreshToken) {
522
+ if (!refreshing) refreshing = tryRefresh(token).finally(() => {
523
+ refreshing = void 0;
524
+ });
525
+ const refreshed = await refreshing;
526
+ if (refreshed) return refreshed;
527
+ }
341
528
  if (!starting) starting = beginAuth();
342
529
  throw createAuthRequiredError(authorizeUrl ?? await starting);
343
530
  };
531
+ const invalidate = () => {
532
+ if (token?.refreshToken) {
533
+ token = {
534
+ accessToken: token.accessToken,
535
+ refreshToken: token.refreshToken,
536
+ expiresAt: 0
537
+ };
538
+ persist(token);
539
+ } else {
540
+ token = void 0;
541
+ clearToken(tokenPath, logger);
542
+ }
543
+ logger.info("oauth_token_invalidated");
544
+ };
344
545
  return {
345
546
  getToken,
346
- setToken
547
+ setToken,
548
+ invalidate
347
549
  };
348
550
  };
349
551
  //#endregion
@@ -373,7 +575,8 @@ const ConfigSchema = z.object({
373
575
  mode: ToolMode,
374
576
  readOnly: z.boolean(),
375
577
  namespaces: z.array(Namespace).optional(),
376
- tools: z.array(z.string()).optional()
578
+ tools: z.array(z.string()).optional(),
579
+ callbackPort: z.number().int().min(1).max(65535).optional()
377
580
  });
378
581
  const parseCliArgs = (args) => {
379
582
  const result = {};
@@ -397,6 +600,9 @@ const parseCliArgs = (args) => {
397
600
  } else if (arg === "--log-level" && next) {
398
601
  result.logLevel = next;
399
602
  i++;
603
+ } else if (arg === "--callback-port" && next) {
604
+ result.callbackPort = Number(next);
605
+ i++;
400
606
  } else if (!arg.startsWith("-") && positionalIndex === 0) {
401
607
  result.subdomain = arg;
402
608
  positionalIndex++;
@@ -409,6 +615,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
409
615
  const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
410
616
  const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
411
617
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
618
+ const envCallbackPort = process.env["ZENDESK_OAUTH_CALLBACK_PORT"];
619
+ const callbackPort = cli.callbackPort ?? (envCallbackPort ? Number(envCallbackPort) : void 0);
412
620
  return ConfigSchema.parse({
413
621
  subdomain,
414
622
  oauthClientId,
@@ -418,27 +626,11 @@ const loadConfig = (argv = process.argv.slice(2)) => {
418
626
  mode,
419
627
  readOnly: cli.readOnly ?? false,
420
628
  namespaces: cli.namespaces,
421
- tools: cli.tools
629
+ tools: cli.tools,
630
+ callbackPort
422
631
  });
423
632
  };
424
633
  //#endregion
425
- //#region src/routing/registry.ts
426
- const filterTools = (allTools, options) => allTools.filter((tool) => {
427
- if (options.readOnly && !tool.readOnly) return false;
428
- if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
429
- if (options.tools?.length && !options.tools.includes(tool.name)) return false;
430
- return true;
431
- });
432
- const groupByNamespace = (tools) => {
433
- const grouped = /* @__PURE__ */ new Map();
434
- for (const tool of tools) {
435
- const existing = grouped.get(tool.namespace) ?? [];
436
- existing.push(tool);
437
- grouped.set(tool.namespace, existing);
438
- }
439
- return grouped;
440
- };
441
- //#endregion
442
634
  //#region src/client/zendesk-api.ts
443
635
  var ZendeskApiError = class ZendeskApiError extends Error {
444
636
  status;
@@ -548,6 +740,23 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
548
740
  return response.json();
549
741
  };
550
742
  //#endregion
743
+ //#region src/routing/registry.ts
744
+ const filterTools = (allTools, options) => allTools.filter((tool) => {
745
+ if (options.readOnly && !tool.readOnly) return false;
746
+ if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
747
+ if (options.tools?.length && !options.tools.includes(tool.name)) return false;
748
+ return true;
749
+ });
750
+ const groupByNamespace = (tools) => {
751
+ const grouped = /* @__PURE__ */ new Map();
752
+ for (const tool of tools) {
753
+ const existing = grouped.get(tool.namespace) ?? [];
754
+ existing.push(tool);
755
+ grouped.set(tool.namespace, existing);
756
+ }
757
+ return grouped;
758
+ };
759
+ //#endregion
551
760
  //#region src/utils/article-sections.ts
552
761
  const HEADING_LEVELS = new Set([
553
762
  "h1",
@@ -1981,46 +2190,21 @@ const createAllTools = (ctx) => [
1981
2190
  ...createUserTools(ctx)
1982
2191
  ];
1983
2192
  //#endregion
1984
- //#region src/utils/package-info.ts
1985
- const FALLBACK = {
1986
- name: "@fruggr/zendesk-mcp-server",
1987
- version: "0.0.0"
1988
- };
2193
+ //#region src/server.ts
1989
2194
  /**
1990
- * Read `name`/`version` from the package's own package.json at runtime instead
1991
- * of hardcoding them. Walks up from this module to the nearest package.json,
1992
- * which resolves correctly both when bundled (`dist/index.js` repo root) and
1993
- * from source/tests (`src/` has no package.json, so the root is found). Reading
1994
- * at runtime (not inlining at build) matters because semantic-release bumps the
1995
- * version into package.json before publishing, after the build step.
2195
+ * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
2196
+ * token (401). This lets the OAuth store drop the dead token so the next call
2197
+ * refreshes/re-authenticates instead of replaying a revoked token. A no-op
2198
+ * callback (API-token mode) leaves behavior unchanged.
1996
2199
  */
1997
- let cached;
1998
- const readPackageInfo = () => {
1999
- if (cached) return cached;
2000
- let dir = dirname(fileURLToPath(import.meta.url));
2001
- for (let depth = 0; depth < 8; depth++) {
2002
- try {
2003
- const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2004
- if (raw && typeof raw === "object") {
2005
- const pkg = raw;
2006
- if (typeof pkg.name === "string" && typeof pkg.version === "string") {
2007
- cached = {
2008
- name: pkg.name,
2009
- version: pkg.version
2010
- };
2011
- return cached;
2012
- }
2013
- }
2014
- } catch {}
2015
- const parent = dirname(dir);
2016
- if (parent === dir) break;
2017
- dir = parent;
2200
+ const runHandler = async (def, params, onUnauthorized) => {
2201
+ try {
2202
+ return await def.handler(params);
2203
+ } catch (err) {
2204
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
2205
+ throw err;
2018
2206
  }
2019
- cached = FALLBACK;
2020
- return cached;
2021
2207
  };
2022
- //#endregion
2023
- //#region src/server.ts
2024
2208
  const NAMESPACE_LABELS = {
2025
2209
  tickets: {
2026
2210
  toolName: "zendesk_tickets",
@@ -2047,7 +2231,7 @@ const aggregateAnnotations = (tools) => ({
2047
2231
  idempotentHint: tools.every((t) => t.annotations.idempotentHint),
2048
2232
  openWorldHint: true
2049
2233
  });
2050
- const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode) => {
2234
+ const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode, onUnauthorized) => {
2051
2235
  const operationNames = tools.map((t) => t.name);
2052
2236
  const operationList = buildOperationList(tools);
2053
2237
  const annotations = aggregateAnnotations(tools);
@@ -2066,11 +2250,10 @@ const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyM
2066
2250
  type: "text",
2067
2251
  text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2068
2252
  }] };
2069
- const validated = def.inputSchema.parse(params);
2070
- return def.handler(validated);
2253
+ return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
2071
2254
  });
2072
2255
  };
2073
- const createMcpServer = (config, getToken, logger = silentLogger) => {
2256
+ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
2074
2257
  const pkg = readPackageInfo();
2075
2258
  const server = new McpServer({
2076
2259
  name: pkg.name,
@@ -2094,18 +2277,18 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
2094
2277
  description: tool.description,
2095
2278
  inputSchema: tool.inputSchema,
2096
2279
  annotations: tool.annotations
2097
- }, async (params) => tool.handler(params));
2280
+ }, async (params) => runHandler(tool, params, onUnauthorized));
2098
2281
  break;
2099
2282
  case "namespace": {
2100
2283
  const grouped = groupByNamespace(filteredTools);
2101
2284
  for (const [namespace, tools] of grouped) {
2102
2285
  const label = NAMESPACE_LABELS[namespace];
2103
- if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly);
2286
+ if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly, onUnauthorized);
2104
2287
  }
2105
2288
  break;
2106
2289
  }
2107
2290
  case "single":
2108
- registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly);
2291
+ registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly, onUnauthorized);
2109
2292
  break;
2110
2293
  }
2111
2294
  logger.info("tools_registered", {
@@ -2130,10 +2313,14 @@ const main = async () => {
2130
2313
  const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2131
2314
  const getToken = () => staticToken;
2132
2315
  await startStdioTransport(createMcpServer(config, getToken, logger), logger);
2133
- } else await startStdioTransport(createMcpServer(config, createTokenStore({
2134
- subdomain: config.subdomain,
2135
- oauthClientId: config.oauthClientId
2136
- }, logger).getToken, logger), logger);
2316
+ } else {
2317
+ const tokenStore = createTokenStore({
2318
+ subdomain: config.subdomain,
2319
+ oauthClientId: config.oauthClientId,
2320
+ callbackPort: config.callbackPort
2321
+ }, logger);
2322
+ await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
2323
+ }
2137
2324
  };
2138
2325
  main().catch((error) => {
2139
2326
  console.error("Fatal error:", error);