@fruggr/zendesk-mcp-server 2.24.0 → 3.0.1

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
@@ -22,7 +22,6 @@ import remarkRehype from "remark-rehype";
22
22
  import remarkStringify from "remark-stringify";
23
23
  import { unified } from "unified";
24
24
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
25
- import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
26
25
  //#region src/utils/logger.ts
27
26
  const SEVERITY = {
28
27
  debug: 0,
@@ -119,53 +118,16 @@ const createLogger = (level) => {
119
118
  };
120
119
  //#endregion
121
120
  //#region src/utils/env.ts
122
- const LEGACY_NAMES = /* @__PURE__ */ new Map([
123
- ["OAUTH_TOKEN_FILE", "ZENDESK_TOKEN_FILE"],
124
- ["OAUTH_CALLBACK_PORT", "ZENDESK_OAUTH_CALLBACK_PORT"],
125
- ["LISTEN_HOST", "HOST"],
126
- ["RESPONSE_CHARACTER_LIMIT", "ZENDESK_CHARACTER_LIMIT"],
127
- ["RESPONSE_MAX_BYTES", "ZENDESK_MAX_RESPONSE_BYTES"],
128
- ["ATTACHMENT_MAX_BYTES", "ZENDESK_MAX_ATTACHMENT_BYTES"],
129
- ["EMBEDDED_IMAGES_MAX", "ZENDESK_MAX_EMBEDDED_IMAGES"],
130
- ["COMMENT_MAX_PAGES", "ZENDESK_MAX_COMMENT_PAGES"],
131
- ["TICKET_FIELD_SCAN_MAX_PAGES", "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES"],
132
- ["ARTICLE_RESOURCES_SCAN_MAX_PAGES", "ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES"],
133
- ["REORDER_CONFIRM_THRESHOLD", "ZENDESK_REORDER_CONFIRM_THRESHOLD"]
134
- ]);
135
- const warned = /* @__PURE__ */ new Set();
136
- const warnDeprecated = (legacy, current) => {
137
- if (warned.has(legacy)) return;
138
- warned.add(legacy);
139
- try {
140
- console.error(`[zendesk-mcp] [warn] deprecated_env_var name=${legacy} replacement=${current} removal=3.0.0`);
141
- } catch {}
142
- };
143
121
  /**
144
- * Reads `name`, falling back to the legacy name it replaced. `name` in the result
145
- * is the variable the value came from, so an error about it names what the
146
- * operator actually set.
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").
147
126
  */
148
- const readEnv = (name) => {
149
- const value = process.env[name];
150
- const legacy = LEGACY_NAMES.get(name);
151
- if (legacy === void 0) return {
152
- name,
153
- value
154
- };
155
- const legacyValue = process.env[legacy];
156
- if (legacyValue === void 0) return {
157
- name,
158
- value
159
- };
160
- warnDeprecated(legacy, name);
161
- return value === void 0 ? {
162
- name: legacy,
163
- value: legacyValue
164
- } : {
165
- name,
166
- value
167
- };
168
- };
127
+ const readEnv = (name) => ({
128
+ name,
129
+ value: process.env[name]
130
+ });
169
131
  //#endregion
170
132
  //#region src/constants.ts
171
133
  const positiveIntEnv = (name, fallback) => {
@@ -193,6 +155,15 @@ const getOAuthUrls = (subdomain) => ({
193
155
  tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
194
156
  });
195
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
196
167
  //#region src/auth/oauth-scopes.ts
197
168
  /**
198
169
  * The OAuth scope the server asks Zendesk for, and the one predicate that
@@ -260,13 +231,6 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
260
231
  code: "EADDRINUSE",
261
232
  cause
262
233
  });
263
- /**
264
- * Escape a string for safe interpolation into HTML text/attribute context.
265
- * The local callback server echoes attacker-controllable values (the OAuth
266
- * `error_description` query param, token-exchange error bodies) back into the
267
- * browser response; without escaping these are a reflected-XSS sink.
268
- */
269
- const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
270
234
  const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
271
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>";
272
236
  const generateCodeVerifier = () => randomBytes(32).toString("base64url");
@@ -871,7 +835,22 @@ const ConfigSchema = z.object({
871
835
  * because they send no Origin header.
872
836
  */
873
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([]),
874
- 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)
875
854
  });
876
855
  const DIGITS_ONLY = /^\d+$/;
877
856
  const parsePort = (raw, label) => {
@@ -909,6 +888,13 @@ const CLI_OPTIONS = {
909
888
  multiple: true
910
889
  },
911
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" },
912
898
  "read-only": { type: "boolean" },
913
899
  "no-topology": { type: "boolean" },
914
900
  "no-promoted-articles": { type: "boolean" },
@@ -925,14 +911,18 @@ const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
925
911
  ["transport", "transport"],
926
912
  ["host", "host"],
927
913
  ["public-url", "publicUrl"],
928
- ["cors-origin", "corsOrigins"]
914
+ ["cors-origin", "corsOrigins"],
915
+ ["oauth-master-secret-file", "oauthMasterSecretFile"],
916
+ ["oauth-store", "oauthStore"],
917
+ ["oauth-trusted-client", "oauthTrustedClients"]
929
918
  ]);
930
919
  const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
931
920
  ["read-only", { readOnly: true }],
932
921
  ["no-topology", { topology: false }],
933
922
  ["no-promoted-articles", { promotedArticles: false }],
934
923
  ["dev", { dev: true }],
935
- ["print-tools", { printTools: true }]
924
+ ["print-tools", { printTools: true }],
925
+ ["no-default-trusted-clients", { defaultTrustedClients: false }]
936
926
  ]);
937
927
  const parseCliArgs = (args) => {
938
928
  const { values, positionals } = parseArgs({
@@ -956,8 +946,9 @@ const parseCliArgs = (args) => {
956
946
  if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
957
947
  return result;
958
948
  };
949
+ const splitList = (raw) => (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
959
950
  const resolveTransportSettings = (cli) => {
960
- const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
951
+ const corsFromEnv = splitList(process.env["CORS_ORIGIN"]);
961
952
  return {
962
953
  transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
963
954
  host: cli.host ?? requireNonEmptyEnv("LISTEN_HOST") ?? "0.0.0.0",
@@ -966,6 +957,13 @@ const resolveTransportSettings = (cli) => {
966
957
  corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
967
958
  };
968
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
+ });
969
967
  const loadConfig = (argv = process.argv.slice(2)) => {
970
968
  const cli = parseCliArgs(argv);
971
969
  const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
@@ -988,7 +986,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
988
986
  dev: cli.dev ?? false,
989
987
  printTools: cli.printTools ?? false,
990
988
  callbackPort,
991
- ...resolveTransportSettings(cli)
989
+ ...resolveTransportSettings(cli),
990
+ ...resolveOAuthServerSettings(cli)
992
991
  });
993
992
  };
994
993
  //#endregion
@@ -1372,6 +1371,50 @@ const replaceSectionContent = (html, sectionIndex, newHtml) => {
1372
1371
  return `<${section.headingTag}>${section.heading}</${section.headingTag}>${content}`;
1373
1372
  }).join("");
1374
1373
  };
1374
+ const LEADING_HEADING = /^\s*<(h[1-3])(?:\s[^>]*)?>([\s\S]*?)<\/\1\s*>/i;
1375
+ const normalizeText = (text) => text.trim().split(WHITESPACE_RUN).join(" ");
1376
+ const stripLeadingDuplicateHeading = (html, target) => {
1377
+ const match = LEADING_HEADING.exec(html);
1378
+ const tag = match?.[1];
1379
+ if (!match || !tag) return {
1380
+ html,
1381
+ stripped: null
1382
+ };
1383
+ const text = normalizeText(textOf(match[2] ?? ""));
1384
+ if (tag.toLowerCase() !== target.headingTag || text !== normalizeText(target.heading)) return {
1385
+ html,
1386
+ stripped: null
1387
+ };
1388
+ return {
1389
+ html: html.slice(match[0].length).trimStart(),
1390
+ stripped: {
1391
+ tag: target.headingTag,
1392
+ text
1393
+ }
1394
+ };
1395
+ };
1396
+ const introLoss = (target, contentSections) => {
1397
+ if (target?.level !== 0) return null;
1398
+ const first = contentSections[0];
1399
+ if (!first) return "empty";
1400
+ return first.level > 0 ? "heading-led" : null;
1401
+ };
1402
+ const applySectionUpdate = (html, sectionIndex, newHtml) => {
1403
+ const sections = parseSections(html);
1404
+ const target = sections[sectionIndex];
1405
+ const { html: content, stripped } = target ? stripLeadingDuplicateHeading(newHtml, target) : {
1406
+ html: newHtml,
1407
+ stripped: null
1408
+ };
1409
+ const contentSections = parseSections(content);
1410
+ return {
1411
+ body: replaceSectionContent(html, sectionIndex, content),
1412
+ stripped,
1413
+ contentHeadings: contentSections.filter((s) => s.level > 0).length,
1414
+ introLost: introLoss(target, contentSections),
1415
+ sectionsBefore: sections.length
1416
+ };
1417
+ };
1375
1418
  const keepAsHtml = (_state, node) => ({
1376
1419
  type: "html",
1377
1420
  value: toHtml(node)
@@ -2362,6 +2405,16 @@ const assertReorderParamsCoherent = (articleId, target, referenceArticleId) => {
2362
2405
  if (!needsReference && referenceArticleId !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
2363
2406
  if (referenceArticleId !== void 0 && referenceArticleId === articleId) throw new Error("reference_article_id must differ from article_id.");
2364
2407
  };
2408
+ const structureWarning = (r) => {
2409
+ if (r.introLost === "empty") {
2410
+ if (r.sectionsBefore === 1) return ["Warning: content is empty and the intro was the only section, so the article body is now empty."];
2411
+ return [`Warning: content is empty, so the intro no longer exists and every later section index moved down by one (article: ${r.sectionsAfter} sections, was ${r.sectionsBefore}). Call get_article_outline before the next edit.`];
2412
+ }
2413
+ if (r.introLost === "heading-led") return [`Warning: content starts with a heading, so the intro no longer exists and section [${r.sectionIndex}] is now a heading section (article: ${r.sectionsAfter} sections, was ${r.sectionsBefore}). Call get_article_outline before the next edit.`];
2414
+ if (r.contentHeadings > 0) return [`Warning: content contains ${r.contentHeadings} heading(s) (h1-h3), so the article now has ${r.sectionsAfter} sections (was ${r.sectionsBefore}); indexes after [${r.sectionIndex}] shifted. Call get_article_outline before the next edit.`];
2415
+ return [];
2416
+ };
2417
+ const sectionUpdateNotes = (r) => [...r.stripped ? [`Note: removed a leading <${r.stripped.tag}> "${r.stripped.text}" from content; the section heading is kept automatically and is not part of content.`] : [], ...structureWarning(r)];
2365
2418
  const createHelpCenterTools = (ctx) => {
2366
2419
  const { subdomain, getToken } = ctx;
2367
2420
  const fetchSectionOrder = async (sectionId, locale, token) => {
@@ -3208,7 +3261,7 @@ const createHelpCenterTools = (ctx) => {
3208
3261
  namespace: "help_center",
3209
3262
  readOnly: true,
3210
3263
  title: "Get Article Section",
3211
- description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section).",
3264
+ description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section). The \"## [n] hN: ...\" line at the top is a label, not part of the content.",
3212
3265
  inputSchema: z.object({
3213
3266
  article_id: z.number().int().describe(ARTICLE_ID_DESC),
3214
3267
  locale: z.string().describe("Locale of the body (e.g., \"en-us\", \"fr\")"),
@@ -3246,12 +3299,12 @@ const createHelpCenterTools = (ctx) => {
3246
3299
  namespace: "help_center",
3247
3300
  readOnly: false,
3248
3301
  title: "Update Article Section",
3249
- description: "Replace the content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. Default format=\"html\" for fidelity. Use format=\"markdown\" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells). The section heading is preserved and is NOT part of the replaced content.",
3302
+ description: "Replace the body of one article section, heading excluded (the heading is kept as is), in a given locale, leaving the rest of the article intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. A leading heading in content that repeats the section heading is removed; any other h1-h3 in content creates new sections and shifts the later indexes (the response warns). section_index 0 is the heading-less intro, which exists only when the body has text before its first heading. Default format=\"html\" for fidelity. Use format=\"markdown\" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells).",
3250
3303
  inputSchema: z.object({
3251
3304
  article_id: z.number().int().describe(ARTICLE_ID_DESC),
3252
3305
  locale: z.string().describe("Locale of the translation to update"),
3253
- section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
3254
- content: z.string().describe("New content for the section (heading excluded). HTML by default, Markdown if format=\"markdown\"."),
3306
+ section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline). 0 is the heading-less intro when the body has text before its first heading, otherwise the first heading."),
3307
+ content: z.string().describe("New content for the section (heading excluded: send neither the heading nor the \"## [n] hN: ...\" label line that get_article_section prints). HTML by default, Markdown if format=\"markdown\"."),
3255
3308
  format: z.enum(["html", "markdown"]).default("html").describe("Input format. \"html\" (default) is the safe path. \"markdown\" is converted to HTML server-side but may introduce artifacts on complex content.")
3256
3309
  }),
3257
3310
  annotations: {
@@ -3265,13 +3318,26 @@ const createHelpCenterTools = (ctx) => {
3265
3318
  const token = await getToken();
3266
3319
  const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${locale}`);
3267
3320
  const newSectionHtml = format === "markdown" ? markdownToHtml(content) : content;
3268
- const newBody = replaceSectionContent(translation.body, section_index, newSectionHtml);
3321
+ const { body: newBody, stripped, contentHeadings, introLost, sectionsBefore } = applySectionUpdate(translation.body, section_index, newSectionHtml);
3269
3322
  const { translation: updated } = await helpCenterPut(subdomain, token, `/articles/${article_id}/translations/${locale}`, { translation: { body: newBody } });
3270
- const updatedSection = parseSections(updated.body)[section_index];
3271
- const newWordCount = updatedSection?.wordCount ?? 0;
3323
+ const updatedSections = parseSections(updated.body);
3324
+ const updatedSection = updatedSections[section_index];
3325
+ const emptied = introLost === "empty";
3326
+ const newWordCount = emptied ? 0 : updatedSection?.wordCount ?? 0;
3272
3327
  return { content: [{
3273
3328
  type: "text",
3274
- text: [`Section [${section_index}] "${updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`, `New word count: ${newWordCount}.`].join("\n")
3329
+ text: [
3330
+ `Section [${section_index}] "${emptied ? "(intro)" : updatedSection?.heading ?? "(intro)"}" updated for article #${article_id} (${locale}).`,
3331
+ `New word count: ${newWordCount}.`,
3332
+ ...sectionUpdateNotes({
3333
+ stripped,
3334
+ contentHeadings,
3335
+ introLost,
3336
+ sectionIndex: section_index,
3337
+ sectionsBefore,
3338
+ sectionsAfter: updatedSections.length
3339
+ })
3340
+ ].join("\n")
3275
3341
  }] };
3276
3342
  }
3277
3343
  },
@@ -5537,358 +5603,34 @@ const renderToolSurface = (config, tools) => {
5537
5603
  ].join("\n");
5538
5604
  };
5539
5605
  //#endregion
5540
- //#region src/transports/http.ts
5541
- const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
5542
- "0.0.0.0",
5543
- "::",
5544
- "*"
5545
- ]);
5546
- const TRAILING_SLASHES = /\/+$/;
5547
- const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
5548
- "https://chatgpt.com",
5549
- "https://chat.openai.com",
5550
- "https://claude.ai",
5551
- "https://gemini.google.com",
5552
- "https://copilot.microsoft.com",
5553
- "https://www.perplexity.ai",
5554
- "https://chat.mistral.ai",
5555
- "https://grok.com"
5556
- ];
5557
- const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
5558
- const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
5559
- const CORS_EXPOSE_HEADERS = "mcp-session-id";
5560
- const CORS_MAX_AGE = "600";
5561
- const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
5562
- "localhost",
5563
- "127.0.0.1",
5564
- "[::1]"
5565
- ]);
5566
- const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
5606
+ //#region src/transports/http-peers.ts
5567
5607
  /**
5568
- * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
5569
- * `undefined` if the origin is not allowed.
5570
- *
5571
- * The returned value is **never the raw `Origin` request header**. It comes
5572
- * from one of three sanitization points:
5573
- *
5574
- * 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
5575
- * 2. An entry of the operator-configured `extraOrigins` array.
5576
- * 3. A loopback origin rebuilt from validated URL components after the
5577
- * hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
5578
- *
5579
- * This shape keeps the dataflow from request header to response header
5580
- * gated by a constant allowlist, which is the pattern CodeQL's
5581
- * `js/cors-misconfiguration-for-credentials` rule recognises as safe when
5582
- * combined with `Access-Control-Allow-Credentials: true`.
5608
+ * Packages only the HTTP transport uses, shipped as optional peer dependencies so
5609
+ * a stdio install (npx, bunx) never downloads them. Inlined from `package.json` at
5610
+ * build time, so a version bump there is the only edit. Rationale:
5611
+ * docs/decisions/oauth-authorization-server.md (Packaging).
5583
5612
  */
5584
- const resolveAllowedOrigin = (origin, extraOrigins) => {
5585
- const defaultMatch = DEFAULT_BROWSER_MCP_CLIENT_ORIGINS.find((entry) => entry === origin);
5586
- if (defaultMatch) return defaultMatch;
5587
- const extraMatch = extraOrigins?.find((entry) => entry === origin);
5588
- if (extraMatch) return extraMatch;
5613
+ const HTTP_PEERS = {
5614
+ "@modelcontextprotocol/node": "^2.0.0",
5615
+ "jose": "^6.2.12",
5616
+ "oidc-provider": "~9.12.2"
5617
+ };
5618
+ const installCommand = () => ["npm install @fruggr/zendesk-mcp-server", ...Object.entries(HTTP_PEERS).map(([name, range]) => `${name}@${range}`)].join(" ");
5619
+ const missingPeers = (err) => {
5620
+ if (err?.code !== "ERR_MODULE_NOT_FOUND") return [];
5621
+ const message = String(err.message);
5622
+ return Object.keys(HTTP_PEERS).filter((name) => message.includes(`'${name}'`));
5623
+ };
5624
+ /** Import the HTTP transport, turning a missing optional peer into an actionable error. */
5625
+ const loadHttpTransport = async (importer = () => import("./http-BXXDmcaZ.js")) => {
5589
5626
  try {
5590
- const url = new URL(origin);
5591
- if (!ALLOWED_PROTOCOLS.has(url.protocol)) return void 0;
5592
- if (!LOCALHOST_HOSTNAMES.has(url.hostname)) return void 0;
5593
- const port = url.port || (url.protocol === "https:" ? "443" : "80");
5594
- return `${url.protocol}//${url.hostname}:${port}`;
5595
- } catch {
5596
- return;
5597
- }
5598
- };
5599
- const applyCorsHeaders = (req, res, extraOrigins) => {
5600
- const requestOrigin = req.headers["origin"];
5601
- if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
5602
- const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
5603
- if (!allowedOrigin) return;
5604
- res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
5605
- res.setHeader("Vary", "Origin");
5606
- res.setHeader("Access-Control-Allow-Credentials", "true");
5607
- res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
5608
- };
5609
- const handleCorsPreflight = (req, res, extraOrigins) => {
5610
- if (req.method !== "OPTIONS") return false;
5611
- applyCorsHeaders(req, res, extraOrigins);
5612
- if (res.getHeader("Access-Control-Allow-Origin")) {
5613
- res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
5614
- res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
5615
- res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
5616
- }
5617
- res.writeHead(204);
5618
- res.end();
5619
- return true;
5620
- };
5621
- const resolveResourceUrl = (config, logger = silentLogger) => {
5622
- if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
5623
- if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
5624
- logger.warn("public_url_unset", {
5625
- host: config.host,
5626
- advertised: `http://${config.host}:${config.port}`,
5627
- 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)."
5628
- });
5629
- return `http://${config.host}:${config.port}`;
5630
- };
5631
- 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.";
5632
- const extractBearer = (request) => {
5633
- const header = request.headers["authorization"];
5634
- if (typeof header !== "string") return void 0;
5635
- if (!header.toLowerCase().startsWith("bearer ")) return void 0;
5636
- return header.slice(7).trim();
5637
- };
5638
- const buildOAuthMetadata = (config, logger = silentLogger) => {
5639
- const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
5640
- const issuer = `https://${config.subdomain}.zendesk.com`;
5641
- const resource = resolveResourceUrl(config, logger);
5642
- const scopes = () => supportedScopes(config.readOnly);
5643
- return {
5644
- protectedResource: {
5645
- authorization_servers: [issuer],
5646
- resource,
5647
- bearer_methods_supported: ["header"],
5648
- scopes_supported: scopes()
5649
- },
5650
- authorizationServer: {
5651
- issuer,
5652
- authorization_endpoint: authorizeUrl,
5653
- token_endpoint: tokenUrl,
5654
- response_types_supported: ["code"],
5655
- grant_types_supported: ["authorization_code", "refresh_token"],
5656
- code_challenge_methods_supported: ["S256"],
5657
- token_endpoint_auth_methods_supported: ["none"],
5658
- scopes_supported: scopes()
5659
- }
5660
- };
5661
- };
5662
- const sendJson = (res, status, body) => {
5663
- res.writeHead(status, { "Content-Type": "application/json" });
5664
- res.end(JSON.stringify(body));
5665
- };
5666
- const sendJsonRpcError = (res, status, code, message, headers = {}) => {
5667
- res.writeHead(status, {
5668
- "Content-Type": "application/json",
5669
- ...headers
5670
- });
5671
- res.end(JSON.stringify({
5672
- error: {
5673
- code,
5674
- message
5675
- },
5676
- id: null,
5677
- jsonrpc: "2.0"
5678
- }));
5679
- };
5680
- const failRequest = (res, err) => {
5681
- const message = err instanceof Error ? err.message : "Internal Server Error";
5682
- if (!res.headersSent) {
5683
- sendJsonRpcError(res, 500, -32603, message);
5684
- return;
5685
- }
5686
- if (!res.writableEnded) res.end();
5687
- };
5688
- const sendUnauthorized = (res, resource) => {
5689
- const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
5690
- sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
5691
- };
5692
- const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
5693
- const chunks = [];
5694
- let total = 0;
5695
- let settled = false;
5696
- const settle = (result) => {
5697
- if (settled) return;
5698
- settled = true;
5699
- resolve(result);
5700
- };
5701
- req.on("data", (chunk) => {
5702
- total += chunk.length;
5703
- if (total > maxBodyBytes) {
5704
- req.removeAllListeners("data");
5705
- settle({
5706
- ok: false,
5707
- status: 413,
5708
- rpcCode: -32600,
5709
- message: `Request body exceeds ${maxBodyBytes} bytes.`
5710
- });
5711
- return;
5712
- }
5713
- chunks.push(chunk);
5714
- });
5715
- req.on("end", () => {
5716
- const raw = Buffer.concat(chunks).toString("utf8");
5717
- if (!raw) {
5718
- settle({
5719
- ok: true,
5720
- value: void 0
5721
- });
5722
- return;
5723
- }
5724
- try {
5725
- settle({
5726
- ok: true,
5727
- value: JSON.parse(raw)
5728
- });
5729
- } catch {
5730
- settle({
5731
- ok: false,
5732
- status: 400,
5733
- rpcCode: -32700,
5734
- message: "Parse error: request body is not valid JSON."
5735
- });
5736
- }
5737
- });
5738
- req.on("error", () => settle({
5739
- ok: false,
5740
- status: 400,
5741
- rpcCode: -32600,
5742
- message: "Request body could not be read."
5743
- }));
5744
- });
5745
- const respondBodyError = (req, res, failure) => {
5746
- const headers = failure.status === 413 ? { Connection: "close" } : {};
5747
- sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
5748
- if (failure.status === 413) {
5749
- if (res.writableFinished) req.destroy();
5750
- else res.once("finish", () => req.destroy());
5627
+ return await importer();
5628
+ } catch (err) {
5629
+ const missing = missingPeers(err);
5630
+ if (missing.length === 0) throw err;
5631
+ 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 });
5751
5632
  }
5752
5633
  };
5753
- const SESSION_IDLE_TIMEOUT_MS = 18e5;
5754
- const SESSION_SWEEP_INTERVAL_MS = 6e4;
5755
- const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
5756
- const metadata = buildOAuthMetadata(config, logger);
5757
- const sessions = /* @__PURE__ */ new Map();
5758
- const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
5759
- const maxBodyBytes = options.maxBodyBytes ?? 4194304;
5760
- const dispatchToSession = async (req, res, sessionId, bearer) => {
5761
- const session = sessions.get(sessionId);
5762
- if (!session) return false;
5763
- session.auth.bearer = bearer;
5764
- session.lastActivityAt = Date.now();
5765
- const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
5766
- ok: true,
5767
- value: void 0
5768
- };
5769
- if (!body.ok) {
5770
- respondBodyError(req, res, body);
5771
- return true;
5772
- }
5773
- await session.transport.handleRequest(req, res, body.value);
5774
- return true;
5775
- };
5776
- const handleMcpRequest = async (req, res) => {
5777
- const bearer = extractBearer(req);
5778
- if (!bearer) {
5779
- sendUnauthorized(res, metadata.protectedResource.resource);
5780
- return;
5781
- }
5782
- const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
5783
- if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
5784
- if (req.method !== "POST") {
5785
- sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
5786
- return;
5787
- }
5788
- const body = await readJsonBody(req, maxBodyBytes);
5789
- if (!body.ok) {
5790
- respondBodyError(req, res, body);
5791
- return;
5792
- }
5793
- const auth = { bearer };
5794
- const server = createMcpServer(config, () => auth.bearer, logger);
5795
- const transport = new NodeStreamableHTTPServerTransport({
5796
- sessionIdGenerator: () => randomUUID(),
5797
- onsessioninitialized: (newId) => {
5798
- sessions.set(newId, {
5799
- transport,
5800
- auth,
5801
- lastActivityAt: Date.now(),
5802
- close: async () => {
5803
- await transport.close();
5804
- await server.close();
5805
- }
5806
- });
5807
- }
5808
- });
5809
- transport.onclose = () => {
5810
- if (transport.sessionId) sessions.delete(transport.sessionId);
5811
- };
5812
- await server.connect(transport);
5813
- await transport.handleRequest(req, res, body.value);
5814
- };
5815
- const staticGetRoutes = {
5816
- "/.well-known/oauth-protected-resource": metadata.protectedResource,
5817
- "/.well-known/oauth-authorization-server": metadata.authorizationServer,
5818
- "/healthz": {
5819
- status: "ok",
5820
- subdomain: config.subdomain
5821
- }
5822
- };
5823
- const requestListener = async (req, res) => {
5824
- try {
5825
- if (handleCorsPreflight(req, res, config.corsOrigins)) return;
5826
- applyCorsHeaders(req, res, config.corsOrigins);
5827
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
5828
- const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
5829
- if (staticRoute) {
5830
- sendJson(res, 200, staticRoute);
5831
- return;
5832
- }
5833
- if (url.pathname === "/mcp") {
5834
- await handleMcpRequest(req, res);
5835
- return;
5836
- }
5837
- res.writeHead(404, { "Content-Type": "application/json" });
5838
- res.end(JSON.stringify({
5839
- error: "Not found",
5840
- path: url.pathname
5841
- }));
5842
- } catch (err) {
5843
- failRequest(res, err);
5844
- }
5845
- };
5846
- const httpServer = createServer((req, res) => {
5847
- requestListener(req, res);
5848
- });
5849
- await new Promise((resolve, reject) => {
5850
- httpServer.once("error", reject);
5851
- httpServer.listen(config.port, config.host, () => {
5852
- httpServer.off("error", reject);
5853
- resolve();
5854
- });
5855
- });
5856
- const addr = httpServer.address();
5857
- const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
5858
- logger.info("http_transport_ready", {
5859
- host: config.host,
5860
- port: boundPort
5861
- });
5862
- const sweepIdleSessions = async () => {
5863
- const cutoff = Date.now() - idleTimeoutMs;
5864
- for (const [id, session] of sessions) {
5865
- if (session.lastActivityAt > cutoff) continue;
5866
- sessions.delete(id);
5867
- try {
5868
- await session.close();
5869
- } catch (err) {
5870
- logger.warn("session_close_failed", {
5871
- sessionId: id,
5872
- error: err instanceof Error ? err.message : String(err)
5873
- });
5874
- }
5875
- }
5876
- };
5877
- const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
5878
- sweeper.unref();
5879
- return {
5880
- port: boundPort,
5881
- close: async () => {
5882
- clearInterval(sweeper);
5883
- await Promise.all([...sessions.values()].map((session) => session.close()));
5884
- sessions.clear();
5885
- httpServer.closeAllConnections();
5886
- await new Promise((resolve, reject) => {
5887
- httpServer.close((err) => err ? reject(err) : resolve());
5888
- });
5889
- }
5890
- };
5891
- };
5892
5634
  //#endregion
5893
5635
  //#region src/utils/shutdown.ts
5894
5636
  /**
@@ -6011,6 +5753,7 @@ const main = async () => {
6011
5753
  return;
6012
5754
  }
6013
5755
  if (config.dev) logger.warn("dev_mode_ignored_http");
5756
+ const { startHttpTransport } = await loadHttpTransport();
6014
5757
  const http = await startHttpTransport(config, logger);
6015
5758
  installShutdown({
6016
5759
  watchStdin: false,
@@ -6023,4 +5766,4 @@ main().catch((error) => {
6023
5766
  process.exit(1);
6024
5767
  });
6025
5768
  //#endregion
6026
- export {};
5769
+ 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 };