@fruggr/zendesk-mcp-server 1.7.0 → 1.9.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 CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { readFileSync } from "node:fs";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
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,7 @@ 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
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
22
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
24
  //#region src/auth/api-token.ts
24
25
  /**
@@ -126,7 +127,6 @@ const getOAuthUrls = (subdomain) => ({
126
127
  });
127
128
  //#endregion
128
129
  //#region src/auth/browser-oauth.ts
129
- const DEFAULT_CALLBACK_PORT = 3e3;
130
130
  const AUTH_TIMEOUT_MS = 300 * 1e3;
131
131
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
132
132
  const detectWsl = () => {
@@ -138,6 +138,16 @@ const detectWsl = () => {
138
138
  }
139
139
  };
140
140
  /**
141
+ * Build an actionable error for a callback port that's already taken. The raw
142
+ * Node `EADDRINUSE` is opaque to both the user and the LLM; this spells out the
143
+ * fix (set a free port + register the matching redirect URL in Zendesk). The
144
+ * `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
145
+ */
146
+ 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)`), {
147
+ code: "EADDRINUSE",
148
+ cause
149
+ });
150
+ /**
141
151
  * Escape a string for safe interpolation into HTML text/attribute context.
142
152
  * The local callback server echoes attacker-controllable values (the OAuth
143
153
  * `error_description` query param, token-exchange error bodies) back into the
@@ -233,12 +243,18 @@ const startBrowserAuth = (config, logger = silentLogger) => {
233
243
  rejectToken(err);
234
244
  }
235
245
  });
246
+ const requestedPort = config.callbackPort ?? 27439;
236
247
  const onStartError = (err) => {
237
248
  clearTimeout(authTimeout);
238
- rejectStarted(err);
249
+ const code = err.code;
250
+ logger.error("oauth_callback_listen_failed", {
251
+ port: requestedPort,
252
+ errorCode: code
253
+ });
254
+ rejectStarted(code === "EADDRINUSE" ? callbackPortInUseError(requestedPort, err) : err);
239
255
  };
240
256
  callbackServer.once("error", onStartError);
241
- callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
257
+ callbackServer.listen(requestedPort, () => {
242
258
  callbackServer.off("error", onStartError);
243
259
  callbackServer.once("error", (err) => {
244
260
  clearTimeout(authTimeout);
@@ -292,34 +308,199 @@ const startBrowserAuth = (config, logger = silentLogger) => {
292
308
  });
293
309
  });
294
310
  };
311
+ /**
312
+ * Exchange a refresh token for a fresh access token (and a rotated refresh token)
313
+ * without any browser interaction. Public PKCE clients send no `client_secret`.
314
+ * Zendesk refresh tokens are single-use: the caller MUST persist the new
315
+ * `refresh_token` from the response. Throws on a non-2xx (expired/invalid
316
+ * refresh token) so the caller can fall back to the full browser flow.
317
+ */
318
+ const refreshAccessToken = async (config, logger = silentLogger) => {
319
+ const { tokenUrl } = getOAuthUrls(config.subdomain);
320
+ const body = new URLSearchParams({
321
+ grant_type: "refresh_token",
322
+ refresh_token: config.refreshToken,
323
+ client_id: config.oauthClientId
324
+ });
325
+ const response = await fetch(tokenUrl, {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
328
+ body: body.toString()
329
+ });
330
+ logger.debug("oauth_token_refresh", { status: response.status });
331
+ if (!response.ok) {
332
+ const errorBody = await response.text();
333
+ throw new Error(`Token refresh failed (${response.status}): ${errorBody}`);
334
+ }
335
+ const tokenData = await response.json();
336
+ logger.info("oauth_token_refreshed");
337
+ return tokenData;
338
+ };
339
+ //#endregion
340
+ //#region src/utils/package-info.ts
341
+ const FALLBACK = {
342
+ name: "@fruggr/zendesk-mcp-server",
343
+ version: "0.0.0"
344
+ };
345
+ /**
346
+ * Read `name`/`version` from the package's own package.json at runtime instead
347
+ * of hardcoding them. Walks up from this module to the nearest package.json,
348
+ * which resolves correctly both when bundled (`dist/index.js` → repo root) and
349
+ * from source/tests (`src/` has no package.json, so the root is found). Reading
350
+ * at runtime (not inlining at build) matters because semantic-release bumps the
351
+ * version into package.json before publishing, after the build step.
352
+ */
353
+ let cached;
354
+ const readPackageInfo = () => {
355
+ if (cached) return cached;
356
+ let dir = dirname(fileURLToPath(import.meta.url));
357
+ for (let depth = 0; depth < 8; depth++) {
358
+ try {
359
+ const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
360
+ if (raw && typeof raw === "object") {
361
+ const pkg = raw;
362
+ if (typeof pkg.name === "string" && typeof pkg.version === "string") {
363
+ cached = {
364
+ name: pkg.name,
365
+ version: pkg.version
366
+ };
367
+ return cached;
368
+ }
369
+ }
370
+ } catch {}
371
+ const parent = dirname(dir);
372
+ if (parent === dir) break;
373
+ dir = parent;
374
+ }
375
+ cached = FALLBACK;
376
+ return cached;
377
+ };
378
+ //#endregion
379
+ //#region src/auth/token-persistence.ts
380
+ const isWindows = process.platform === "win32";
381
+ /**
382
+ * Config-dir segments derived from the *scoped* package name
383
+ * (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
384
+ * vendor-namespaced and can't collide with another `zendesk-mcp-server`.
385
+ */
386
+ const appDirSegments = () => {
387
+ const { name } = readPackageInfo();
388
+ const scoped = /^@([^/]+)\/(.+)$/.exec(name);
389
+ return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
390
+ };
391
+ const configDir = () => {
392
+ const segments = appDirSegments();
393
+ if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
394
+ return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
395
+ };
396
+ const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
397
+ /**
398
+ * Path to the token file for a subdomain. Each subdomain gets its **own** file
399
+ * (`<subdomain>.json`, a single record) so concurrent processes for different
400
+ * subdomains never read-modify-write a shared file — no merge, no clobber.
401
+ * `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
402
+ * default layout for multi-subdomain installs).
403
+ */
404
+ const resolveTokenPath = (subdomain) => {
405
+ const override = process.env["ZENDESK_TOKEN_FILE"];
406
+ if (override) return override;
407
+ return join(configDir(), `${safeName(subdomain)}.json`);
408
+ };
409
+ const writeFileAtomic = (path, record) => {
410
+ const dir = dirname(path);
411
+ mkdirSync(dir, { recursive: true });
412
+ const tmp = `${path}.${process.pid}.tmp`;
413
+ writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
414
+ if (!isWindows) chmodSync(tmp, 384);
415
+ renameSync(tmp, path);
416
+ if (!isWindows) try {
417
+ chmodSync(dir, 448);
418
+ } catch {}
419
+ };
420
+ const loadToken = (path) => {
421
+ try {
422
+ const raw = JSON.parse(readFileSync(path, "utf8"));
423
+ if (raw && typeof raw === "object" && typeof raw.accessToken === "string") return raw;
424
+ } catch {}
425
+ };
426
+ const saveToken = (path, record, logger = silentLogger) => {
427
+ try {
428
+ writeFileAtomic(path, record);
429
+ logger.debug("token_persisted");
430
+ } catch (err) {
431
+ logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
432
+ }
433
+ };
434
+ const clearToken = (path, logger = silentLogger) => {
435
+ try {
436
+ rmSync(path, { force: true });
437
+ logger.debug("token_cleared");
438
+ } catch (err) {
439
+ logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
440
+ }
441
+ };
295
442
  //#endregion
296
443
  //#region src/auth/token-store.ts
444
+ const EXPIRY_SKEW_MS = 6e4;
297
445
  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
446
  name: "AuthRequiredError",
299
447
  authorizeUrl
300
448
  });
449
+ const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
301
450
  const createTokenStore = (config, logger = silentLogger) => {
302
- let token;
451
+ const tokenPath = resolveTokenPath(config.subdomain);
452
+ let token = loadToken(tokenPath);
453
+ if (token) logger.debug("oauth_token_loaded_from_disk");
303
454
  let authorizeUrl;
304
455
  let starting;
456
+ let refreshing;
457
+ const persist = (t) => saveToken(tokenPath, t, logger);
305
458
  const setToken = (accessToken, refreshToken) => {
306
459
  token = {
307
460
  accessToken,
308
461
  refreshToken
309
462
  };
463
+ persist(token);
464
+ };
465
+ const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
466
+ const tryRefresh = async (current) => {
467
+ if (!current.refreshToken) return void 0;
468
+ try {
469
+ const result = await refreshAccessToken({
470
+ subdomain: config.subdomain,
471
+ oauthClientId: config.oauthClientId,
472
+ refreshToken: current.refreshToken
473
+ }, logger);
474
+ token = {
475
+ accessToken: result.access_token,
476
+ refreshToken: result.refresh_token ?? current.refreshToken,
477
+ expiresAt: expiryFrom(result.expires_in)
478
+ };
479
+ persist(token);
480
+ logger.info("oauth_token_refreshed_cached");
481
+ return token.accessToken;
482
+ } catch (err) {
483
+ logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
484
+ token = void 0;
485
+ clearToken(tokenPath, logger);
486
+ return;
487
+ }
310
488
  };
311
489
  const beginAuth = () => {
312
490
  logger.info("oauth_auth_start");
313
491
  return startBrowserAuth({
314
492
  subdomain: config.subdomain,
315
- oauthClientId: config.oauthClientId
493
+ oauthClientId: config.oauthClientId,
494
+ callbackPort: config.callbackPort
316
495
  }, logger).then((started) => {
317
496
  authorizeUrl = started.authorizeUrl;
318
497
  started.tokenPromise.then((result) => {
319
498
  token = {
320
499
  accessToken: result.access_token,
321
- refreshToken: result.refresh_token
500
+ refreshToken: result.refresh_token,
501
+ expiresAt: expiryFrom(result.expires_in)
322
502
  };
503
+ persist(token);
323
504
  logger.info("oauth_token_cached");
324
505
  }).catch((err) => {
325
506
  logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
@@ -334,16 +515,38 @@ const createTokenStore = (config, logger = silentLogger) => {
334
515
  });
335
516
  };
336
517
  const getToken = async () => {
337
- if (token) {
518
+ if (token && !isExpired(token)) {
338
519
  logger.debug("oauth_token_cache_hit");
339
520
  return token.accessToken;
340
521
  }
522
+ if (token?.refreshToken) {
523
+ if (!refreshing) refreshing = tryRefresh(token).finally(() => {
524
+ refreshing = void 0;
525
+ });
526
+ const refreshed = await refreshing;
527
+ if (refreshed) return refreshed;
528
+ }
341
529
  if (!starting) starting = beginAuth();
342
530
  throw createAuthRequiredError(authorizeUrl ?? await starting);
343
531
  };
532
+ const invalidate = () => {
533
+ if (token?.refreshToken) {
534
+ token = {
535
+ accessToken: token.accessToken,
536
+ refreshToken: token.refreshToken,
537
+ expiresAt: 0
538
+ };
539
+ persist(token);
540
+ } else {
541
+ token = void 0;
542
+ clearToken(tokenPath, logger);
543
+ }
544
+ logger.info("oauth_token_invalidated");
545
+ };
344
546
  return {
345
547
  getToken,
346
- setToken
548
+ setToken,
549
+ invalidate
347
550
  };
348
551
  };
349
552
  //#endregion
@@ -364,6 +567,7 @@ const Namespace = z.enum([
364
567
  "help_center",
365
568
  "users"
366
569
  ]);
570
+ const Transport = z.enum(["stdio", "http"]);
367
571
  const ConfigSchema = z.object({
368
572
  subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
369
573
  oauthClientId: z.string().min(1),
@@ -373,8 +577,26 @@ const ConfigSchema = z.object({
373
577
  mode: ToolMode,
374
578
  readOnly: z.boolean(),
375
579
  namespaces: z.array(Namespace).optional(),
376
- tools: z.array(z.string()).optional()
580
+ tools: z.array(z.string()).optional(),
581
+ transport: Transport,
582
+ host: z.string().min(1),
583
+ port: z.number().int().min(0).max(65535),
584
+ publicUrl: z.string().url().optional(),
585
+ /**
586
+ * Additional browser origins allowed by CORS in HTTP mode. The default
587
+ * allowlist (the major web MCP clients + localhost-any-port for dev) is
588
+ * always applied; this list extends it. Native MCP clients (Claude
589
+ * Desktop, Claude Code CLI, Cursor, VS Code, Zed…) are unaffected
590
+ * because they send no Origin header.
591
+ */
592
+ corsOrigins: z.array(z.string().url().transform((value) => new URL(value).origin).refine((origin) => origin !== "null", { message: "CORS origin must be an http(s) URL with a host" })).default([]),
593
+ callbackPort: z.number().int().min(1).max(65535).optional()
377
594
  });
595
+ const parsePort = (raw, label) => {
596
+ if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
597
+ return Number(raw);
598
+ };
599
+ const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
378
600
  const parseCliArgs = (args) => {
379
601
  const result = {};
380
602
  let positionalIndex = 0;
@@ -397,6 +619,25 @@ const parseCliArgs = (args) => {
397
619
  } else if (arg === "--log-level" && next) {
398
620
  result.logLevel = next;
399
621
  i++;
622
+ } else if (arg === "--transport" && next) {
623
+ result.transport = next;
624
+ i++;
625
+ } else if (arg === "--host" && next) {
626
+ result.host = next;
627
+ i++;
628
+ } else if (arg === "--port" && next) {
629
+ result.port = parsePort(next, "--port");
630
+ i++;
631
+ } else if (arg === "--public-url" && next) {
632
+ result.publicUrl = next;
633
+ i++;
634
+ } else if (arg === "--cors-origin" && next) {
635
+ result.corsOrigins = result.corsOrigins ?? [];
636
+ result.corsOrigins.push(next);
637
+ i++;
638
+ } else if (arg === "--callback-port" && next) {
639
+ result.callbackPort = parsePort(next, "--callback-port");
640
+ i++;
400
641
  } else if (!arg.startsWith("-") && positionalIndex === 0) {
401
642
  result.subdomain = arg;
402
643
  positionalIndex++;
@@ -409,36 +650,35 @@ const loadConfig = (argv = process.argv.slice(2)) => {
409
650
  const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
410
651
  const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
411
652
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
653
+ const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
654
+ const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
655
+ const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
656
+ const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
657
+ const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
658
+ const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
659
+ const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
660
+ const zendeskEmail = process.env["ZENDESK_EMAIL"];
661
+ const zendeskApiToken = process.env["ZENDESK_API_TOKEN"];
662
+ if (transport === "http" && zendeskEmail && zendeskApiToken) throw new Error("API token authentication (ZENDESK_EMAIL + ZENDESK_API_TOKEN) is not supported in HTTP mode. HTTP mode requires per-user OAuth 2.1 PKCE - unset these variables and configure your MCP client to perform the OAuth flow against Zendesk.");
412
663
  return ConfigSchema.parse({
413
664
  subdomain,
414
665
  oauthClientId,
415
- zendeskEmail: process.env["ZENDESK_EMAIL"],
416
- zendeskApiToken: process.env["ZENDESK_API_TOKEN"],
666
+ zendeskEmail,
667
+ zendeskApiToken,
417
668
  logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
418
669
  mode,
419
670
  readOnly: cli.readOnly ?? false,
420
671
  namespaces: cli.namespaces,
421
- tools: cli.tools
672
+ tools: cli.tools,
673
+ transport,
674
+ host,
675
+ port,
676
+ publicUrl,
677
+ corsOrigins,
678
+ callbackPort
422
679
  });
423
680
  };
424
681
  //#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
682
  //#region src/client/zendesk-api.ts
443
683
  var ZendeskApiError = class ZendeskApiError extends Error {
444
684
  status;
@@ -548,6 +788,23 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
548
788
  return response.json();
549
789
  };
550
790
  //#endregion
791
+ //#region src/routing/registry.ts
792
+ const filterTools = (allTools, options) => allTools.filter((tool) => {
793
+ if (options.readOnly && !tool.readOnly) return false;
794
+ if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
795
+ if (options.tools?.length && !options.tools.includes(tool.name)) return false;
796
+ return true;
797
+ });
798
+ const groupByNamespace = (tools) => {
799
+ const grouped = /* @__PURE__ */ new Map();
800
+ for (const tool of tools) {
801
+ const existing = grouped.get(tool.namespace) ?? [];
802
+ existing.push(tool);
803
+ grouped.set(tool.namespace, existing);
804
+ }
805
+ return grouped;
806
+ };
807
+ //#endregion
551
808
  //#region src/utils/article-sections.ts
552
809
  const HEADING_LEVELS = new Set([
553
810
  "h1",
@@ -1981,46 +2238,21 @@ const createAllTools = (ctx) => [
1981
2238
  ...createUserTools(ctx)
1982
2239
  ];
1983
2240
  //#endregion
1984
- //#region src/utils/package-info.ts
1985
- const FALLBACK = {
1986
- name: "@fruggr/zendesk-mcp-server",
1987
- version: "0.0.0"
1988
- };
2241
+ //#region src/server.ts
1989
2242
  /**
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.
2243
+ * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
2244
+ * token (401). This lets the OAuth store drop the dead token so the next call
2245
+ * refreshes/re-authenticates instead of replaying a revoked token. A no-op
2246
+ * callback (API-token mode) leaves behavior unchanged.
1996
2247
  */
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;
2248
+ const runHandler = async (def, params, onUnauthorized) => {
2249
+ try {
2250
+ return await def.handler(params);
2251
+ } catch (err) {
2252
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
2253
+ throw err;
2018
2254
  }
2019
- cached = FALLBACK;
2020
- return cached;
2021
2255
  };
2022
- //#endregion
2023
- //#region src/server.ts
2024
2256
  const NAMESPACE_LABELS = {
2025
2257
  tickets: {
2026
2258
  toolName: "zendesk_tickets",
@@ -2047,30 +2279,36 @@ const aggregateAnnotations = (tools) => ({
2047
2279
  idempotentHint: tools.every((t) => t.annotations.idempotentHint),
2048
2280
  openWorldHint: true
2049
2281
  });
2050
- const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode) => {
2282
+ const buildProxyDispatch = (tools, onUnauthorized) => {
2283
+ const operationNames = tools.map((t) => t.name);
2284
+ const localHandlers = new Map(tools.map((t) => [t.name, t]));
2285
+ return async (args) => {
2286
+ const { operation, params } = args;
2287
+ const def = localHandlers.get(operation);
2288
+ if (!def) return { content: [{
2289
+ type: "text",
2290
+ text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2291
+ }] };
2292
+ return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
2293
+ };
2294
+ };
2295
+ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
2051
2296
  const operationNames = tools.map((t) => t.name);
2052
2297
  const operationList = buildOperationList(tools);
2053
2298
  const annotations = aggregateAnnotations(tools);
2054
2299
  const prefix = readOnlyMode ? "[RO] " : "";
2300
+ const dispatch = buildProxyDispatch(tools, onUnauthorized);
2055
2301
  server.registerTool(toolName, {
2056
2302
  title,
2057
2303
  description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
2058
- inputSchema: z.object({
2304
+ inputSchema: {
2059
2305
  operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
2060
2306
  params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
2061
- }),
2307
+ },
2062
2308
  annotations
2063
- }, async ({ operation, params }) => {
2064
- const def = handlerMap.get(operation);
2065
- if (!def) return { content: [{
2066
- type: "text",
2067
- text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2068
- }] };
2069
- const validated = def.inputSchema.parse(params);
2070
- return def.handler(validated);
2071
- });
2309
+ }, async (args) => dispatch(args));
2072
2310
  };
2073
- const createMcpServer = (config, getToken, logger = silentLogger) => {
2311
+ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
2074
2312
  const pkg = readPackageInfo();
2075
2313
  const server = new McpServer({
2076
2314
  name: pkg.name,
@@ -2085,27 +2323,25 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
2085
2323
  namespaces: config.namespaces,
2086
2324
  tools: config.tools
2087
2325
  });
2088
- const handlerMap = /* @__PURE__ */ new Map();
2089
- for (const tool of filteredTools) handlerMap.set(tool.name, tool);
2090
2326
  switch (config.mode) {
2091
2327
  case "all":
2092
2328
  for (const tool of filteredTools) server.registerTool(tool.name, {
2093
2329
  title: tool.title,
2094
2330
  description: tool.description,
2095
- inputSchema: tool.inputSchema,
2331
+ inputSchema: tool.inputSchema.shape,
2096
2332
  annotations: tool.annotations
2097
- }, async (params) => tool.handler(params));
2333
+ }, async (params) => runHandler(tool, params, onUnauthorized));
2098
2334
  break;
2099
2335
  case "namespace": {
2100
2336
  const grouped = groupByNamespace(filteredTools);
2101
2337
  for (const [namespace, tools] of grouped) {
2102
2338
  const label = NAMESPACE_LABELS[namespace];
2103
- if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly);
2339
+ if (label) registerProxyTool(server, label.toolName, label.title, tools, config.readOnly, onUnauthorized);
2104
2340
  }
2105
2341
  break;
2106
2342
  }
2107
2343
  case "single":
2108
- registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly);
2344
+ registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
2109
2345
  break;
2110
2346
  }
2111
2347
  logger.info("tools_registered", {
@@ -2115,6 +2351,350 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
2115
2351
  return server;
2116
2352
  };
2117
2353
  //#endregion
2354
+ //#region src/transports/http.ts
2355
+ const WILDCARD_HOSTS = new Set([
2356
+ "0.0.0.0",
2357
+ "::",
2358
+ "*"
2359
+ ]);
2360
+ const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
2361
+ "https://chatgpt.com",
2362
+ "https://chat.openai.com",
2363
+ "https://claude.ai",
2364
+ "https://gemini.google.com",
2365
+ "https://copilot.microsoft.com",
2366
+ "https://www.perplexity.ai",
2367
+ "https://chat.mistral.ai",
2368
+ "https://grok.com"
2369
+ ];
2370
+ const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
2371
+ const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
2372
+ const CORS_EXPOSE_HEADERS = "mcp-session-id";
2373
+ const CORS_MAX_AGE = "600";
2374
+ const LOCALHOST_HOSTNAMES = new Set([
2375
+ "localhost",
2376
+ "127.0.0.1",
2377
+ "[::1]"
2378
+ ]);
2379
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
2380
+ /**
2381
+ * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
2382
+ * `undefined` if the origin is not allowed.
2383
+ *
2384
+ * The returned value is **never the raw `Origin` request header**. It comes
2385
+ * from one of three sanitization points:
2386
+ *
2387
+ * 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
2388
+ * 2. An entry of the operator-configured `extraOrigins` array.
2389
+ * 3. A loopback origin rebuilt from validated URL components after the
2390
+ * hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
2391
+ *
2392
+ * This shape keeps the dataflow from request header to response header
2393
+ * gated by a constant allowlist, which is the pattern CodeQL's
2394
+ * `js/cors-misconfiguration-for-credentials` rule recognises as safe when
2395
+ * combined with `Access-Control-Allow-Credentials: true`.
2396
+ */
2397
+ const resolveAllowedOrigin = (origin, extraOrigins) => {
2398
+ const defaultMatch = DEFAULT_BROWSER_MCP_CLIENT_ORIGINS.find((entry) => entry === origin);
2399
+ if (defaultMatch) return defaultMatch;
2400
+ const extraMatch = extraOrigins?.find((entry) => entry === origin);
2401
+ if (extraMatch) return extraMatch;
2402
+ try {
2403
+ const url = new URL(origin);
2404
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) return void 0;
2405
+ if (!LOCALHOST_HOSTNAMES.has(url.hostname)) return void 0;
2406
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
2407
+ return `${url.protocol}//${url.hostname}:${port}`;
2408
+ } catch {
2409
+ return;
2410
+ }
2411
+ };
2412
+ const applyCorsHeaders = (req, res, extraOrigins) => {
2413
+ const requestOrigin = req.headers["origin"];
2414
+ if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
2415
+ const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
2416
+ if (!allowedOrigin) return;
2417
+ res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
2418
+ res.setHeader("Vary", "Origin");
2419
+ res.setHeader("Access-Control-Allow-Credentials", "true");
2420
+ res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
2421
+ };
2422
+ const handleCorsPreflight = (req, res, extraOrigins) => {
2423
+ if (req.method !== "OPTIONS") return false;
2424
+ applyCorsHeaders(req, res, extraOrigins);
2425
+ if (res.getHeader("Access-Control-Allow-Origin")) {
2426
+ res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
2427
+ res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
2428
+ res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
2429
+ }
2430
+ res.writeHead(204);
2431
+ res.end();
2432
+ return true;
2433
+ };
2434
+ const resolveResourceUrl = (config, logger = silentLogger) => {
2435
+ if (config.publicUrl) return config.publicUrl.replace(/\/+$/, "");
2436
+ if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
2437
+ logger.warn("public_url_unset", {
2438
+ host: config.host,
2439
+ advertised: `http://${config.host}:${config.port}`,
2440
+ hint: "OAuth discovery will advertise a non-routable resource identifier and spec-compliant MCP clients may refuse the connection. Set PUBLIC_URL (or --public-url) to the URL clients use to reach this server (e.g. https://your-host.example.com)."
2441
+ });
2442
+ return `http://${config.host}:${config.port}`;
2443
+ };
2444
+ const MISSING_BEARER_MESSAGE = "Missing Authorization: Bearer <zendesk-oauth-token> header. HTTP mode requires per-user OAuth 2.1 PKCE - obtain a token from Zendesk via your MCP client.";
2445
+ const extractBearer = (request) => {
2446
+ const header = request.headers["authorization"];
2447
+ if (typeof header !== "string") return void 0;
2448
+ if (!header.toLowerCase().startsWith("bearer ")) return void 0;
2449
+ return header.slice(7).trim();
2450
+ };
2451
+ const buildOAuthMetadata = (config, logger = silentLogger) => {
2452
+ const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
2453
+ const issuer = `https://${config.subdomain}.zendesk.com`;
2454
+ const resource = resolveResourceUrl(config, logger);
2455
+ return {
2456
+ protectedResource: {
2457
+ authorization_servers: [issuer],
2458
+ resource,
2459
+ bearer_methods_supported: ["header"],
2460
+ scopes_supported: ["read", "write"]
2461
+ },
2462
+ authorizationServer: {
2463
+ issuer,
2464
+ authorization_endpoint: authorizeUrl,
2465
+ token_endpoint: tokenUrl,
2466
+ response_types_supported: ["code"],
2467
+ grant_types_supported: ["authorization_code", "refresh_token"],
2468
+ code_challenge_methods_supported: ["S256"],
2469
+ token_endpoint_auth_methods_supported: ["none"],
2470
+ scopes_supported: ["read", "write"]
2471
+ }
2472
+ };
2473
+ };
2474
+ const sendJson = (res, status, body) => {
2475
+ res.writeHead(status, { "Content-Type": "application/json" });
2476
+ res.end(JSON.stringify(body));
2477
+ };
2478
+ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
2479
+ res.writeHead(status, {
2480
+ "Content-Type": "application/json",
2481
+ ...headers
2482
+ });
2483
+ res.end(JSON.stringify({
2484
+ error: {
2485
+ code,
2486
+ message
2487
+ },
2488
+ id: null,
2489
+ jsonrpc: "2.0"
2490
+ }));
2491
+ };
2492
+ const sendUnauthorized = (res, resource) => {
2493
+ sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"` });
2494
+ };
2495
+ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
2496
+ const chunks = [];
2497
+ let total = 0;
2498
+ let settled = false;
2499
+ const settle = (result) => {
2500
+ if (settled) return;
2501
+ settled = true;
2502
+ resolve(result);
2503
+ };
2504
+ req.on("data", (chunk) => {
2505
+ total += chunk.length;
2506
+ if (total > maxBodyBytes) {
2507
+ req.removeAllListeners("data");
2508
+ settle({
2509
+ ok: false,
2510
+ status: 413,
2511
+ rpcCode: -32600,
2512
+ message: `Request body exceeds ${maxBodyBytes} bytes.`
2513
+ });
2514
+ return;
2515
+ }
2516
+ chunks.push(chunk);
2517
+ });
2518
+ req.on("end", () => {
2519
+ const raw = Buffer.concat(chunks).toString("utf8");
2520
+ if (!raw) {
2521
+ settle({
2522
+ ok: true,
2523
+ value: void 0
2524
+ });
2525
+ return;
2526
+ }
2527
+ try {
2528
+ settle({
2529
+ ok: true,
2530
+ value: JSON.parse(raw)
2531
+ });
2532
+ } catch {
2533
+ settle({
2534
+ ok: false,
2535
+ status: 400,
2536
+ rpcCode: -32700,
2537
+ message: "Parse error: request body is not valid JSON."
2538
+ });
2539
+ }
2540
+ });
2541
+ req.on("error", () => settle({
2542
+ ok: false,
2543
+ status: 400,
2544
+ rpcCode: -32600,
2545
+ message: "Request body could not be read."
2546
+ }));
2547
+ });
2548
+ const respondBodyError = (req, res, failure) => {
2549
+ const headers = failure.status === 413 ? { Connection: "close" } : {};
2550
+ sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
2551
+ if (failure.status === 413) if (res.writableFinished) req.destroy();
2552
+ else res.once("finish", () => req.destroy());
2553
+ };
2554
+ const SESSION_IDLE_TIMEOUT_MS = 1800 * 1e3;
2555
+ const SESSION_SWEEP_INTERVAL_MS = 60 * 1e3;
2556
+ const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
2557
+ const metadata = buildOAuthMetadata(config, logger);
2558
+ const sessions = /* @__PURE__ */ new Map();
2559
+ const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
2560
+ const maxBodyBytes = options.maxBodyBytes ?? 4194304;
2561
+ const handleMcpRequest = async (req, res) => {
2562
+ const bearer = extractBearer(req);
2563
+ if (!bearer) {
2564
+ sendUnauthorized(res, metadata.protectedResource.resource);
2565
+ return;
2566
+ }
2567
+ const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
2568
+ if (sessionId) {
2569
+ const session = sessions.get(sessionId);
2570
+ if (session) {
2571
+ session.auth.bearer = bearer;
2572
+ session.lastActivityAt = Date.now();
2573
+ const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
2574
+ ok: true,
2575
+ value: void 0
2576
+ };
2577
+ if (!body.ok) {
2578
+ respondBodyError(req, res, body);
2579
+ return;
2580
+ }
2581
+ await session.transport.handleRequest(req, res, body.value);
2582
+ return;
2583
+ }
2584
+ }
2585
+ if (req.method !== "POST") {
2586
+ sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
2587
+ return;
2588
+ }
2589
+ const body = await readJsonBody(req, maxBodyBytes);
2590
+ if (!body.ok) {
2591
+ respondBodyError(req, res, body);
2592
+ return;
2593
+ }
2594
+ const auth = { bearer };
2595
+ const server = createMcpServer(config, () => auth.bearer, logger);
2596
+ const transport = new StreamableHTTPServerTransport({
2597
+ sessionIdGenerator: () => randomUUID(),
2598
+ onsessioninitialized: (newId) => {
2599
+ sessions.set(newId, {
2600
+ transport,
2601
+ auth,
2602
+ lastActivityAt: Date.now(),
2603
+ close: async () => {
2604
+ await transport.close();
2605
+ await server.close();
2606
+ }
2607
+ });
2608
+ }
2609
+ });
2610
+ transport.onclose = () => {
2611
+ if (transport.sessionId) sessions.delete(transport.sessionId);
2612
+ };
2613
+ await server.connect(transport);
2614
+ await transport.handleRequest(req, res, body.value);
2615
+ };
2616
+ const requestListener = async (req, res) => {
2617
+ try {
2618
+ if (handleCorsPreflight(req, res, config.corsOrigins)) return;
2619
+ applyCorsHeaders(req, res, config.corsOrigins);
2620
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
2621
+ if (url.pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
2622
+ sendJson(res, 200, metadata.protectedResource);
2623
+ return;
2624
+ }
2625
+ if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
2626
+ sendJson(res, 200, metadata.authorizationServer);
2627
+ return;
2628
+ }
2629
+ if (url.pathname === "/healthz" && req.method === "GET") {
2630
+ sendJson(res, 200, {
2631
+ status: "ok",
2632
+ subdomain: config.subdomain
2633
+ });
2634
+ return;
2635
+ }
2636
+ if (url.pathname === "/mcp") {
2637
+ await handleMcpRequest(req, res);
2638
+ return;
2639
+ }
2640
+ res.writeHead(404, { "Content-Type": "application/json" });
2641
+ res.end(JSON.stringify({
2642
+ error: "Not found",
2643
+ path: url.pathname
2644
+ }));
2645
+ } catch (err) {
2646
+ const message = err instanceof Error ? err.message : "Internal Server Error";
2647
+ if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
2648
+ else if (!res.writableEnded) res.end();
2649
+ }
2650
+ };
2651
+ const httpServer = createServer((req, res) => {
2652
+ requestListener(req, res);
2653
+ });
2654
+ await new Promise((resolve, reject) => {
2655
+ httpServer.once("error", reject);
2656
+ httpServer.listen(config.port, config.host, () => {
2657
+ httpServer.off("error", reject);
2658
+ resolve();
2659
+ });
2660
+ });
2661
+ const addr = httpServer.address();
2662
+ const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
2663
+ logger.info("http_transport_ready", {
2664
+ host: config.host,
2665
+ port: boundPort
2666
+ });
2667
+ const sweepIdleSessions = async () => {
2668
+ const cutoff = Date.now() - idleTimeoutMs;
2669
+ for (const [id, session] of sessions) {
2670
+ if (session.lastActivityAt > cutoff) continue;
2671
+ sessions.delete(id);
2672
+ try {
2673
+ await session.close();
2674
+ } catch (err) {
2675
+ logger.warn("session_close_failed", {
2676
+ sessionId: id,
2677
+ error: err instanceof Error ? err.message : String(err)
2678
+ });
2679
+ }
2680
+ }
2681
+ };
2682
+ const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
2683
+ sweeper.unref();
2684
+ return {
2685
+ port: boundPort,
2686
+ close: async () => {
2687
+ clearInterval(sweeper);
2688
+ await Promise.all([...sessions.values()].map((session) => session.close()));
2689
+ sessions.clear();
2690
+ httpServer.closeAllConnections();
2691
+ await new Promise((resolve, reject) => {
2692
+ httpServer.close((err) => err ? reject(err) : resolve());
2693
+ });
2694
+ }
2695
+ };
2696
+ };
2697
+ //#endregion
2118
2698
  //#region src/transports/stdio.ts
2119
2699
  const startStdioTransport = async (server, logger = silentLogger) => {
2120
2700
  const transport = new StdioServerTransport();
@@ -2123,17 +2703,26 @@ const startStdioTransport = async (server, logger = silentLogger) => {
2123
2703
  };
2124
2704
  //#endregion
2125
2705
  //#region src/index.ts
2126
- const main = async () => {
2127
- const config = loadConfig();
2128
- const logger = createLogger(config.logLevel);
2706
+ const buildStdioServer = (config, logger) => {
2129
2707
  if (config.zendeskEmail && config.zendeskApiToken) {
2130
2708
  const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2131
- const getToken = () => staticToken;
2132
- await startStdioTransport(createMcpServer(config, getToken, logger), logger);
2133
- } else await startStdioTransport(createMcpServer(config, createTokenStore({
2709
+ return createMcpServer(config, () => staticToken, logger);
2710
+ }
2711
+ const tokenStore = createTokenStore({
2134
2712
  subdomain: config.subdomain,
2135
- oauthClientId: config.oauthClientId
2136
- }, logger).getToken, logger), logger);
2713
+ oauthClientId: config.oauthClientId,
2714
+ callbackPort: config.callbackPort
2715
+ }, logger);
2716
+ return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
2717
+ };
2718
+ const main = async () => {
2719
+ const config = loadConfig();
2720
+ const logger = createLogger(config.logLevel);
2721
+ if (config.transport === "stdio") {
2722
+ await startStdioTransport(buildStdioServer(config, logger), logger);
2723
+ return;
2724
+ }
2725
+ await startHttpTransport(config, logger);
2137
2726
  };
2138
2727
  main().catch((error) => {
2139
2728
  console.error("Fatal error:", error);