@fruggr/zendesk-mcp-server 2.23.0 → 3.0.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
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { parseArgs } from "node:util";
11
11
  import * as z from "zod/v4";
12
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
13
13
  import * as cheerio from "cheerio";
14
14
  import { toHtml } from "hast-util-to-html";
15
15
  import rehypeParse from "rehype-parse";
@@ -21,8 +21,7 @@ import remarkParse from "remark-parse";
21
21
  import remarkRehype from "remark-rehype";
22
22
  import remarkStringify from "remark-stringify";
23
23
  import { unified } from "unified";
24
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
26
25
  //#region src/utils/logger.ts
27
26
  const SEVERITY = {
28
27
  debug: 0,
@@ -118,25 +117,37 @@ const createLogger = (level) => {
118
117
  };
119
118
  };
120
119
  //#endregion
120
+ //#region src/utils/env.ts
121
+ /**
122
+ * Reads `name` from the environment. `name` in the result is the variable the
123
+ * value came from, so an error about it names what the operator actually set.
124
+ * Every variable goes through here, so a future rename has a single place to
125
+ * map the old name (AGENTS.md, "Environment variables").
126
+ */
127
+ const readEnv = (name) => ({
128
+ name,
129
+ value: process.env[name]
130
+ });
131
+ //#endregion
121
132
  //#region src/constants.ts
122
133
  const positiveIntEnv = (name, fallback) => {
123
- const raw = process.env[name];
134
+ const raw = readEnv(name).value;
124
135
  if (raw === void 0 || raw.trim() === "") return fallback;
125
136
  const parsed = Number(raw);
126
137
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
127
138
  };
128
- const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
129
- const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
130
- const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
131
- const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
139
+ const CHARACTER_LIMIT = positiveIntEnv("RESPONSE_CHARACTER_LIMIT", 25e3);
140
+ const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
141
+ const MAX_ATTACHMENT_BYTES = positiveIntEnv("ATTACHMENT_MAX_BYTES", 5242880);
142
+ const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("EMBEDDED_IMAGES_MAX", 10);
132
143
  const STDIO_MAX_MESSAGE_BYTES = 10485760;
133
144
  const MESSAGE_CONTENT_BUDGET_BYTES = 10420224;
134
- const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
145
+ const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("RESPONSE_MAX_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
135
146
  const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
136
147
  const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
137
- const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
138
- const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", 10);
139
- const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
148
+ const MAX_COMMENT_PAGES = positiveIntEnv("COMMENT_MAX_PAGES", 10);
149
+ const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("TICKET_FIELD_SCAN_MAX_PAGES", 10);
150
+ const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("REORDER_CONFIRM_THRESHOLD", 20);
140
151
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
141
152
  const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
142
153
  const getOAuthUrls = (subdomain) => ({
@@ -144,6 +155,15 @@ const getOAuthUrls = (subdomain) => ({
144
155
  tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
145
156
  });
146
157
  //#endregion
158
+ //#region src/utils/html.ts
159
+ /**
160
+ * Escape a string for safe interpolation into HTML text/attribute context.
161
+ * Both the stdio callback page and the HTTP consent page echo
162
+ * attacker-controllable values (an OAuth `error_description`, a client name
163
+ * from a fetched metadata document); without escaping these are an XSS sink.
164
+ */
165
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
166
+ //#endregion
147
167
  //#region src/auth/oauth-scopes.ts
148
168
  /**
149
169
  * The OAuth scope the server asks Zendesk for, and the one predicate that
@@ -178,7 +198,7 @@ const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
178
198
  * Whether a grant still covers what this process needs: a flat subset test.
179
199
  * Coverage, not equality, so a token Zendesk granted wider than requested stays
180
200
  * 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
201
+ * reachable now through a `OAUTH_TOKEN_FILE` aimed at one by hand, since the
182
202
  * default layout no longer names those files. No scope hierarchy: granular
183
203
  * scopes (#284) replace this.
184
204
  */
@@ -207,17 +227,10 @@ const detectWsl = () => {
207
227
  * (`docs/decisions/token-file-keying.md`). The `(EADDRINUSE)` marker and `code`
208
228
  * are kept for diagnostics/tests.
209
229
  */
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)`), {
230
+ 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 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)`), {
211
231
  code: "EADDRINUSE",
212
232
  cause
213
233
  });
214
- /**
215
- * Escape a string for safe interpolation into HTML text/attribute context.
216
- * The local callback server echoes attacker-controllable values (the OAuth
217
- * `error_description` query param, token-exchange error bodies) back into the
218
- * browser response; without escaping these are a reflected-XSS sink.
219
- */
220
- const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
221
234
  const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
222
235
  const SUCCESS_PAGE = "<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</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>";
223
236
  const generateCodeVerifier = () => randomBytes(32).toString("base64url");
@@ -500,11 +513,12 @@ const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
500
513
  * their own record instead of clobbering a shared file. Why that triple, and
501
514
  * why no migration from the old subdomain-only layout:
502
515
  * `docs/decisions/token-file-keying.md`.
503
- * `ZENDESK_TOKEN_FILE` overrides with an explicit path — the way to separate
516
+ * `OAUTH_TOKEN_FILE` overrides with an explicit path — the way to separate
504
517
  * two Zendesk accounts that share a subdomain, client and scope.
505
518
  */
506
519
  const resolveTokenPath = (key) => {
507
- const override = process.env["ZENDESK_TOKEN_FILE"];
520
+ const { name, value: override } = readEnv("OAUTH_TOKEN_FILE");
521
+ if (override === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
508
522
  if (override) return override;
509
523
  const readable = [
510
524
  key.subdomain,
@@ -821,18 +835,37 @@ const ConfigSchema = z.object({
821
835
  * because they send no Origin header.
822
836
  */
823
837
  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([]),
824
- callbackPort: z.number().int().min(1).max(65535).optional()
838
+ callbackPort: z.number().int().min(1).max(65535).optional(),
839
+ /**
840
+ * HTTP only: the authorization server's master secret (`OAUTH_MASTER_SECRET`),
841
+ * or a file holding it. Both optional: when absent, one is generated and
842
+ * persisted in the config dir. Belongs to this server, not to Zendesk, hence
843
+ * no `ZENDESK_` prefix. Validated (length, base64) where it is used, so a
844
+ * value never reaches a schema error message.
845
+ */
846
+ oauthMasterSecret: z.string().optional(),
847
+ oauthMasterSecretFile: z.string().min(1).optional(),
848
+ /** HTTP only: the grant store URI; defaults to a file in the config dir. */
849
+ oauthStore: z.string().url().optional(),
850
+ /** HTTP only: CIMD client ids added to the consent-skip allowlist. */
851
+ oauthTrustedClients: z.array(z.string().url()).default([]),
852
+ /** HTTP only: whether the built-in allowlist (claude.ai, ChatGPT) applies. */
853
+ defaultTrustedClients: z.boolean().default(true)
825
854
  });
826
855
  const DIGITS_ONLY = /^\d+$/;
827
856
  const parsePort = (raw, label) => {
828
857
  if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
829
858
  return Number(raw);
830
859
  };
831
- const parsePortEnv = (raw, label) => raw === void 0 ? void 0 : parsePort(raw, label);
832
- const requireNonEmptyEnv = (name) => {
833
- const raw = process.env[name];
834
- if (raw === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
835
- return raw;
860
+ const readNonEmptyEnv = (name) => {
861
+ const env = readEnv(name);
862
+ if (env.value === "") throw new Error(`Empty ${env.name}. Set it to a value, or unset it entirely.`);
863
+ return env;
864
+ };
865
+ const requireNonEmptyEnv = (name) => readNonEmptyEnv(name).value;
866
+ const portEnv = (name) => {
867
+ const env = readNonEmptyEnv(name);
868
+ return env.value === void 0 ? void 0 : parsePort(env.value, env.name);
836
869
  };
837
870
  const CLI_OPTIONS = {
838
871
  mode: { type: "string" },
@@ -855,6 +888,13 @@ const CLI_OPTIONS = {
855
888
  multiple: true
856
889
  },
857
890
  "callback-port": { type: "string" },
891
+ "oauth-master-secret-file": { type: "string" },
892
+ "oauth-store": { type: "string" },
893
+ "oauth-trusted-client": {
894
+ type: "string",
895
+ multiple: true
896
+ },
897
+ "no-default-trusted-clients": { type: "boolean" },
858
898
  "read-only": { type: "boolean" },
859
899
  "no-topology": { type: "boolean" },
860
900
  "no-promoted-articles": { type: "boolean" },
@@ -871,14 +911,18 @@ const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
871
911
  ["transport", "transport"],
872
912
  ["host", "host"],
873
913
  ["public-url", "publicUrl"],
874
- ["cors-origin", "corsOrigins"]
914
+ ["cors-origin", "corsOrigins"],
915
+ ["oauth-master-secret-file", "oauthMasterSecretFile"],
916
+ ["oauth-store", "oauthStore"],
917
+ ["oauth-trusted-client", "oauthTrustedClients"]
875
918
  ]);
876
919
  const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
877
920
  ["read-only", { readOnly: true }],
878
921
  ["no-topology", { topology: false }],
879
922
  ["no-promoted-articles", { promotedArticles: false }],
880
923
  ["dev", { dev: true }],
881
- ["print-tools", { printTools: true }]
924
+ ["print-tools", { printTools: true }],
925
+ ["no-default-trusted-clients", { defaultTrustedClients: false }]
882
926
  ]);
883
927
  const parseCliArgs = (args) => {
884
928
  const { values, positionals } = parseArgs({
@@ -902,23 +946,31 @@ const parseCliArgs = (args) => {
902
946
  if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
903
947
  return result;
904
948
  };
949
+ const splitList = (raw) => (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
905
950
  const resolveTransportSettings = (cli) => {
906
- const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
951
+ const corsFromEnv = splitList(process.env["CORS_ORIGIN"]);
907
952
  return {
908
953
  transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
909
- host: cli.host ?? requireNonEmptyEnv("HOST") ?? "0.0.0.0",
910
- port: cli.port ?? parsePortEnv(requireNonEmptyEnv("PORT"), "PORT") ?? 3e3,
954
+ host: cli.host ?? requireNonEmptyEnv("LISTEN_HOST") ?? "0.0.0.0",
955
+ port: cli.port ?? portEnv("PORT") ?? 3e3,
911
956
  publicUrl: cli.publicUrl ?? requireNonEmptyEnv("PUBLIC_URL"),
912
957
  corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
913
958
  };
914
959
  };
960
+ const resolveOAuthServerSettings = (cli) => ({
961
+ oauthMasterSecret: requireNonEmptyEnv("OAUTH_MASTER_SECRET"),
962
+ oauthMasterSecretFile: cli.oauthMasterSecretFile ?? requireNonEmptyEnv("OAUTH_MASTER_SECRET_FILE"),
963
+ oauthStore: cli.oauthStore ?? requireNonEmptyEnv("OAUTH_STORE"),
964
+ oauthTrustedClients: [...cli.oauthTrustedClients ?? [], ...splitList(readEnv("OAUTH_TRUSTED_CLIENTS").value)],
965
+ defaultTrustedClients: cli.defaultTrustedClients
966
+ });
915
967
  const loadConfig = (argv = process.argv.slice(2)) => {
916
968
  const cli = parseCliArgs(argv);
917
969
  const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
918
970
  const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
919
971
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
920
972
  const namespaces = cli.namespaces ?? (cli.tools?.length ? [...Namespace.options] : void 0);
921
- const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
973
+ const callbackPort = cli.callbackPort ?? portEnv("OAUTH_CALLBACK_PORT");
922
974
  const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
923
975
  return ConfigSchema.parse({
924
976
  subdomain,
@@ -934,7 +986,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
934
986
  dev: cli.dev ?? false,
935
987
  printTools: cli.printTools ?? false,
936
988
  callbackPort,
937
- ...resolveTransportSettings(cli)
989
+ ...resolveTransportSettings(cli),
990
+ ...resolveOAuthServerSettings(cli)
938
991
  });
939
992
  };
940
993
  //#endregion
@@ -2297,7 +2350,7 @@ const localePrefix = (locale) => locale ? `/${locale}` : "";
2297
2350
  const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
2298
2351
  const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
2299
2352
  const scanCostNote = (truncated, pagesScanned, cost) => {
2300
- if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
2353
+ if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
2301
2354
  if (pagesScanned > 1) return `\n\n_Note: this scan cost ${cost}; this tool performs a fresh scan every call (no caching), so avoid calling it again right away._`;
2302
2355
  return "";
2303
2356
  };
@@ -2522,7 +2575,7 @@ const createHelpCenterTools = (ctx) => {
2522
2575
  namespace: "help_center",
2523
2576
  readOnly: true,
2524
2577
  title: "List Promoted Help Center Articles",
2525
- description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
2578
+ description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
2526
2579
  inputSchema: z.object({}),
2527
2580
  annotations: {
2528
2581
  readOnlyHint: true,
@@ -2900,7 +2953,7 @@ const createHelpCenterTools = (ctx) => {
2900
2953
  ]).describe("Where to move the article relative to its section siblings: \"top\" (becomes first), \"bottom\" (becomes last), or \"before\"/\"after\" a specific reference article. \"before\" and \"after\" require reference_article_id."),
2901
2954
  reference_article_id: z.number().int().optional().describe("The sibling article to position next to when target is \"before\" or \"after\" (numeric id from list_articles). Must belong to the same section and differ from article_id; leave it unset for \"top\" or \"bottom\"."),
2902
2955
  normalize: z.boolean().default(false).describe("When true, also renumber every article in the section to contiguous positions (0, 1, 2, …) so the stored positions stay tidy. Defaults to false, which writes the fewest positions possible and lets gaps remain. Either way the confirmation threshold still applies."),
2903
- confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (ZENDESK_REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
2956
+ confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
2904
2957
  }),
2905
2958
  annotations: {
2906
2959
  readOnlyHint: false,
@@ -3383,7 +3436,7 @@ const END_USER_FORM_PARAMS = {
3383
3436
  * form invisible or a required field unenforced, and the caller then gets a
3384
3437
  * confidently wrong "no such form" or an opaque Zendesk 422.
3385
3438
  */
3386
- const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
3439
+ const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
3387
3440
  const items = [];
3388
3441
  let page = 1;
3389
3442
  while (true) {
@@ -3440,7 +3493,7 @@ const fetchAllRequestComments = async (subdomain, token, requestId) => {
3440
3493
  extract: (response) => response.comments,
3441
3494
  params: { include: "users" },
3442
3495
  maxPages: MAX_COMMENT_PAGES,
3443
- capEnvVar: "ZENDESK_MAX_COMMENT_PAGES",
3496
+ capEnvVar: "COMMENT_MAX_PAGES",
3444
3497
  forbiddenHint: OTHER_USERS_REQUEST_HINT,
3445
3498
  onPage: (response) => {
3446
3499
  for (const user of response.users ?? []) authors.set(user.id, user);
@@ -5116,10 +5169,10 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
5116
5169
  return server.registerTool(toolName, {
5117
5170
  title,
5118
5171
  description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
5119
- inputSchema: {
5172
+ inputSchema: z.object({
5120
5173
  operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
5121
5174
  params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
5122
- },
5175
+ }),
5123
5176
  annotations
5124
5177
  }, async (args) => dispatch(args));
5125
5178
  };
@@ -5483,358 +5536,34 @@ const renderToolSurface = (config, tools) => {
5483
5536
  ].join("\n");
5484
5537
  };
5485
5538
  //#endregion
5486
- //#region src/transports/http.ts
5487
- const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
5488
- "0.0.0.0",
5489
- "::",
5490
- "*"
5491
- ]);
5492
- const TRAILING_SLASHES = /\/+$/;
5493
- const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
5494
- "https://chatgpt.com",
5495
- "https://chat.openai.com",
5496
- "https://claude.ai",
5497
- "https://gemini.google.com",
5498
- "https://copilot.microsoft.com",
5499
- "https://www.perplexity.ai",
5500
- "https://chat.mistral.ai",
5501
- "https://grok.com"
5502
- ];
5503
- const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
5504
- const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
5505
- const CORS_EXPOSE_HEADERS = "mcp-session-id";
5506
- const CORS_MAX_AGE = "600";
5507
- const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
5508
- "localhost",
5509
- "127.0.0.1",
5510
- "[::1]"
5511
- ]);
5512
- const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
5539
+ //#region src/transports/http-peers.ts
5513
5540
  /**
5514
- * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
5515
- * `undefined` if the origin is not allowed.
5516
- *
5517
- * The returned value is **never the raw `Origin` request header**. It comes
5518
- * from one of three sanitization points:
5519
- *
5520
- * 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
5521
- * 2. An entry of the operator-configured `extraOrigins` array.
5522
- * 3. A loopback origin rebuilt from validated URL components after the
5523
- * hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
5524
- *
5525
- * This shape keeps the dataflow from request header to response header
5526
- * gated by a constant allowlist, which is the pattern CodeQL's
5527
- * `js/cors-misconfiguration-for-credentials` rule recognises as safe when
5528
- * combined with `Access-Control-Allow-Credentials: true`.
5541
+ * Packages only the HTTP transport uses, shipped as optional peer dependencies so
5542
+ * a stdio install (npx, bunx) never downloads them. Inlined from `package.json` at
5543
+ * build time, so a version bump there is the only edit. Rationale:
5544
+ * docs/decisions/oauth-authorization-server.md (Packaging).
5529
5545
  */
5530
- const resolveAllowedOrigin = (origin, extraOrigins) => {
5531
- const defaultMatch = DEFAULT_BROWSER_MCP_CLIENT_ORIGINS.find((entry) => entry === origin);
5532
- if (defaultMatch) return defaultMatch;
5533
- const extraMatch = extraOrigins?.find((entry) => entry === origin);
5534
- if (extraMatch) return extraMatch;
5546
+ const HTTP_PEERS = {
5547
+ "@modelcontextprotocol/node": "^2.0.0",
5548
+ "jose": "^6.2.12",
5549
+ "oidc-provider": "~9.12.2"
5550
+ };
5551
+ const installCommand = () => ["npm install @fruggr/zendesk-mcp-server", ...Object.entries(HTTP_PEERS).map(([name, range]) => `${name}@${range}`)].join(" ");
5552
+ const missingPeers = (err) => {
5553
+ if (err?.code !== "ERR_MODULE_NOT_FOUND") return [];
5554
+ const message = String(err.message);
5555
+ return Object.keys(HTTP_PEERS).filter((name) => message.includes(`'${name}'`));
5556
+ };
5557
+ /** Import the HTTP transport, turning a missing optional peer into an actionable error. */
5558
+ const loadHttpTransport = async (importer = () => import("./http-BXXDmcaZ.js")) => {
5535
5559
  try {
5536
- const url = new URL(origin);
5537
- if (!ALLOWED_PROTOCOLS.has(url.protocol)) return void 0;
5538
- if (!LOCALHOST_HOSTNAMES.has(url.hostname)) return void 0;
5539
- const port = url.port || (url.protocol === "https:" ? "443" : "80");
5540
- return `${url.protocol}//${url.hostname}:${port}`;
5541
- } catch {
5542
- return;
5543
- }
5544
- };
5545
- const applyCorsHeaders = (req, res, extraOrigins) => {
5546
- const requestOrigin = req.headers["origin"];
5547
- if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
5548
- const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
5549
- if (!allowedOrigin) return;
5550
- res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
5551
- res.setHeader("Vary", "Origin");
5552
- res.setHeader("Access-Control-Allow-Credentials", "true");
5553
- res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
5554
- };
5555
- const handleCorsPreflight = (req, res, extraOrigins) => {
5556
- if (req.method !== "OPTIONS") return false;
5557
- applyCorsHeaders(req, res, extraOrigins);
5558
- if (res.getHeader("Access-Control-Allow-Origin")) {
5559
- res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
5560
- res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
5561
- res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
5562
- }
5563
- res.writeHead(204);
5564
- res.end();
5565
- return true;
5566
- };
5567
- const resolveResourceUrl = (config, logger = silentLogger) => {
5568
- if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
5569
- if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
5570
- logger.warn("public_url_unset", {
5571
- host: config.host,
5572
- advertised: `http://${config.host}:${config.port}`,
5573
- 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)."
5574
- });
5575
- return `http://${config.host}:${config.port}`;
5576
- };
5577
- 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.";
5578
- const extractBearer = (request) => {
5579
- const header = request.headers["authorization"];
5580
- if (typeof header !== "string") return void 0;
5581
- if (!header.toLowerCase().startsWith("bearer ")) return void 0;
5582
- return header.slice(7).trim();
5583
- };
5584
- const buildOAuthMetadata = (config, logger = silentLogger) => {
5585
- const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
5586
- const issuer = `https://${config.subdomain}.zendesk.com`;
5587
- const resource = resolveResourceUrl(config, logger);
5588
- const scopes = () => supportedScopes(config.readOnly);
5589
- return {
5590
- protectedResource: {
5591
- authorization_servers: [issuer],
5592
- resource,
5593
- bearer_methods_supported: ["header"],
5594
- scopes_supported: scopes()
5595
- },
5596
- authorizationServer: {
5597
- issuer,
5598
- authorization_endpoint: authorizeUrl,
5599
- token_endpoint: tokenUrl,
5600
- response_types_supported: ["code"],
5601
- grant_types_supported: ["authorization_code", "refresh_token"],
5602
- code_challenge_methods_supported: ["S256"],
5603
- token_endpoint_auth_methods_supported: ["none"],
5604
- scopes_supported: scopes()
5605
- }
5606
- };
5607
- };
5608
- const sendJson = (res, status, body) => {
5609
- res.writeHead(status, { "Content-Type": "application/json" });
5610
- res.end(JSON.stringify(body));
5611
- };
5612
- const sendJsonRpcError = (res, status, code, message, headers = {}) => {
5613
- res.writeHead(status, {
5614
- "Content-Type": "application/json",
5615
- ...headers
5616
- });
5617
- res.end(JSON.stringify({
5618
- error: {
5619
- code,
5620
- message
5621
- },
5622
- id: null,
5623
- jsonrpc: "2.0"
5624
- }));
5625
- };
5626
- const failRequest = (res, err) => {
5627
- const message = err instanceof Error ? err.message : "Internal Server Error";
5628
- if (!res.headersSent) {
5629
- sendJsonRpcError(res, 500, -32603, message);
5630
- return;
5631
- }
5632
- if (!res.writableEnded) res.end();
5633
- };
5634
- const sendUnauthorized = (res, resource) => {
5635
- const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
5636
- sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
5637
- };
5638
- const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
5639
- const chunks = [];
5640
- let total = 0;
5641
- let settled = false;
5642
- const settle = (result) => {
5643
- if (settled) return;
5644
- settled = true;
5645
- resolve(result);
5646
- };
5647
- req.on("data", (chunk) => {
5648
- total += chunk.length;
5649
- if (total > maxBodyBytes) {
5650
- req.removeAllListeners("data");
5651
- settle({
5652
- ok: false,
5653
- status: 413,
5654
- rpcCode: -32600,
5655
- message: `Request body exceeds ${maxBodyBytes} bytes.`
5656
- });
5657
- return;
5658
- }
5659
- chunks.push(chunk);
5660
- });
5661
- req.on("end", () => {
5662
- const raw = Buffer.concat(chunks).toString("utf8");
5663
- if (!raw) {
5664
- settle({
5665
- ok: true,
5666
- value: void 0
5667
- });
5668
- return;
5669
- }
5670
- try {
5671
- settle({
5672
- ok: true,
5673
- value: JSON.parse(raw)
5674
- });
5675
- } catch {
5676
- settle({
5677
- ok: false,
5678
- status: 400,
5679
- rpcCode: -32700,
5680
- message: "Parse error: request body is not valid JSON."
5681
- });
5682
- }
5683
- });
5684
- req.on("error", () => settle({
5685
- ok: false,
5686
- status: 400,
5687
- rpcCode: -32600,
5688
- message: "Request body could not be read."
5689
- }));
5690
- });
5691
- const respondBodyError = (req, res, failure) => {
5692
- const headers = failure.status === 413 ? { Connection: "close" } : {};
5693
- sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
5694
- if (failure.status === 413) {
5695
- if (res.writableFinished) req.destroy();
5696
- else res.once("finish", () => req.destroy());
5560
+ return await importer();
5561
+ } catch (err) {
5562
+ const missing = missingPeers(err);
5563
+ if (missing.length === 0) throw err;
5564
+ throw new Error(`The HTTP transport needs optional packages that are not installed (missing: ${missing.join(", ")}). Install them next to the server:\n ${installCommand()}\nstdio does not need them. See docs/http-deployment.md.`, { cause: err });
5697
5565
  }
5698
5566
  };
5699
- const SESSION_IDLE_TIMEOUT_MS = 18e5;
5700
- const SESSION_SWEEP_INTERVAL_MS = 6e4;
5701
- const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
5702
- const metadata = buildOAuthMetadata(config, logger);
5703
- const sessions = /* @__PURE__ */ new Map();
5704
- const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
5705
- const maxBodyBytes = options.maxBodyBytes ?? 4194304;
5706
- const dispatchToSession = async (req, res, sessionId, bearer) => {
5707
- const session = sessions.get(sessionId);
5708
- if (!session) return false;
5709
- session.auth.bearer = bearer;
5710
- session.lastActivityAt = Date.now();
5711
- const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
5712
- ok: true,
5713
- value: void 0
5714
- };
5715
- if (!body.ok) {
5716
- respondBodyError(req, res, body);
5717
- return true;
5718
- }
5719
- await session.transport.handleRequest(req, res, body.value);
5720
- return true;
5721
- };
5722
- const handleMcpRequest = async (req, res) => {
5723
- const bearer = extractBearer(req);
5724
- if (!bearer) {
5725
- sendUnauthorized(res, metadata.protectedResource.resource);
5726
- return;
5727
- }
5728
- const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
5729
- if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
5730
- if (req.method !== "POST") {
5731
- sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
5732
- return;
5733
- }
5734
- const body = await readJsonBody(req, maxBodyBytes);
5735
- if (!body.ok) {
5736
- respondBodyError(req, res, body);
5737
- return;
5738
- }
5739
- const auth = { bearer };
5740
- const server = createMcpServer(config, () => auth.bearer, logger);
5741
- const transport = new StreamableHTTPServerTransport({
5742
- sessionIdGenerator: () => randomUUID(),
5743
- onsessioninitialized: (newId) => {
5744
- sessions.set(newId, {
5745
- transport,
5746
- auth,
5747
- lastActivityAt: Date.now(),
5748
- close: async () => {
5749
- await transport.close();
5750
- await server.close();
5751
- }
5752
- });
5753
- }
5754
- });
5755
- transport.onclose = () => {
5756
- if (transport.sessionId) sessions.delete(transport.sessionId);
5757
- };
5758
- await server.connect(transport);
5759
- await transport.handleRequest(req, res, body.value);
5760
- };
5761
- const staticGetRoutes = {
5762
- "/.well-known/oauth-protected-resource": metadata.protectedResource,
5763
- "/.well-known/oauth-authorization-server": metadata.authorizationServer,
5764
- "/healthz": {
5765
- status: "ok",
5766
- subdomain: config.subdomain
5767
- }
5768
- };
5769
- const requestListener = async (req, res) => {
5770
- try {
5771
- if (handleCorsPreflight(req, res, config.corsOrigins)) return;
5772
- applyCorsHeaders(req, res, config.corsOrigins);
5773
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
5774
- const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
5775
- if (staticRoute) {
5776
- sendJson(res, 200, staticRoute);
5777
- return;
5778
- }
5779
- if (url.pathname === "/mcp") {
5780
- await handleMcpRequest(req, res);
5781
- return;
5782
- }
5783
- res.writeHead(404, { "Content-Type": "application/json" });
5784
- res.end(JSON.stringify({
5785
- error: "Not found",
5786
- path: url.pathname
5787
- }));
5788
- } catch (err) {
5789
- failRequest(res, err);
5790
- }
5791
- };
5792
- const httpServer = createServer((req, res) => {
5793
- requestListener(req, res);
5794
- });
5795
- await new Promise((resolve, reject) => {
5796
- httpServer.once("error", reject);
5797
- httpServer.listen(config.port, config.host, () => {
5798
- httpServer.off("error", reject);
5799
- resolve();
5800
- });
5801
- });
5802
- const addr = httpServer.address();
5803
- const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
5804
- logger.info("http_transport_ready", {
5805
- host: config.host,
5806
- port: boundPort
5807
- });
5808
- const sweepIdleSessions = async () => {
5809
- const cutoff = Date.now() - idleTimeoutMs;
5810
- for (const [id, session] of sessions) {
5811
- if (session.lastActivityAt > cutoff) continue;
5812
- sessions.delete(id);
5813
- try {
5814
- await session.close();
5815
- } catch (err) {
5816
- logger.warn("session_close_failed", {
5817
- sessionId: id,
5818
- error: err instanceof Error ? err.message : String(err)
5819
- });
5820
- }
5821
- }
5822
- };
5823
- const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
5824
- sweeper.unref();
5825
- return {
5826
- port: boundPort,
5827
- close: async () => {
5828
- clearInterval(sweeper);
5829
- await Promise.all([...sessions.values()].map((session) => session.close()));
5830
- sessions.clear();
5831
- httpServer.closeAllConnections();
5832
- await new Promise((resolve, reject) => {
5833
- httpServer.close((err) => err ? reject(err) : resolve());
5834
- });
5835
- }
5836
- };
5837
- };
5838
5567
  //#endregion
5839
5568
  //#region src/utils/shutdown.ts
5840
5569
  /**
@@ -5957,6 +5686,7 @@ const main = async () => {
5957
5686
  return;
5958
5687
  }
5959
5688
  if (config.dev) logger.warn("dev_mode_ignored_http");
5689
+ const { startHttpTransport } = await loadHttpTransport();
5960
5690
  const http = await startHttpTransport(config, logger);
5961
5691
  installShutdown({
5962
5692
  watchStdin: false,
@@ -5969,4 +5699,4 @@ main().catch((error) => {
5969
5699
  process.exit(1);
5970
5700
  });
5971
5701
  //#endregion
5972
- export {};
5702
+ export { configDir as a, requestedScope as c, getOAuthUrls as d, silentLogger as f, deadlineSignal as i, supportedScopes as l, zendeskGet as n, generateCodeChallenge as o, REQUEST_TIMEOUT_MS as r, generateCodeVerifier as s, createMcpServer as t, escapeHtml as u };