@fruggr/zendesk-mcp-server 2.24.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
@@ -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
@@ -5537,358 +5536,34 @@ const renderToolSurface = (config, tools) => {
5537
5536
  ].join("\n");
5538
5537
  };
5539
5538
  //#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:"]);
5539
+ //#region src/transports/http-peers.ts
5567
5540
  /**
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`.
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).
5583
5545
  */
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;
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")) => {
5589
5559
  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());
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 });
5751
5565
  }
5752
5566
  };
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
5567
  //#endregion
5893
5568
  //#region src/utils/shutdown.ts
5894
5569
  /**
@@ -6011,6 +5686,7 @@ const main = async () => {
6011
5686
  return;
6012
5687
  }
6013
5688
  if (config.dev) logger.warn("dev_mode_ignored_http");
5689
+ const { startHttpTransport } = await loadHttpTransport();
6014
5690
  const http = await startHttpTransport(config, logger);
6015
5691
  installShutdown({
6016
5692
  watchStdin: false,
@@ -6023,4 +5699,4 @@ main().catch((error) => {
6023
5699
  process.exit(1);
6024
5700
  });
6025
5701
  //#endregion
6026
- 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.24.0",
3
+ "version": "3.0.0",
4
4
  "mcpName": "io.github.fruggr/zendesk-mcp-server",
5
5
  "description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
6
6
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  "typecheck": "node scripts/typecheck.mjs",
19
19
  "start": "node dist/index.js",
20
20
  "dev": "tsx watch src/index.ts",
21
+ "dev:http": "tsx src/index.ts --transport http --host 127.0.0.1 --port 3000 --public-url http://localhost:3000 --mode all --namespace tickets --namespace help_center --namespace users --namespace requests",
21
22
  "prepare": "npm run build && (lefthook install || true)",
22
23
  "prepublishOnly": "npm run build",
23
24
  "check": "node scripts/biome.mjs check --error-on-warnings .",
@@ -68,11 +69,10 @@
68
69
  "access": "public"
69
70
  },
70
71
  "engines": {
71
- "node": ">=20"
72
+ "node": ">=22"
72
73
  },
73
- "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f",
74
+ "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
74
75
  "dependencies": {
75
- "@modelcontextprotocol/node": "2.0.0",
76
76
  "@modelcontextprotocol/server": "2.0.0",
77
77
  "cheerio": "1.2.0",
78
78
  "hast-util-to-html": "9.0.5",
@@ -89,9 +89,26 @@
89
89
  "unified": "11.0.5",
90
90
  "zod": "4.6.5"
91
91
  },
92
+ "peerDependencies": {
93
+ "@modelcontextprotocol/node": "^2.0.0",
94
+ "jose": "^6.2.12",
95
+ "oidc-provider": "~9.12.2"
96
+ },
97
+ "peerDependenciesMeta": {
98
+ "@modelcontextprotocol/node": {
99
+ "optional": true
100
+ },
101
+ "jose": {
102
+ "optional": true
103
+ },
104
+ "oidc-provider": {
105
+ "optional": true
106
+ }
107
+ },
92
108
  "devDependencies": {
93
- "@biomejs/biome": "2.5.13",
109
+ "@biomejs/biome": "2.5.14",
94
110
  "@modelcontextprotocol/client": "2.0.0",
111
+ "@modelcontextprotocol/node": "2.0.0",
95
112
  "@semantic-release/changelog": "^7.0.0",
96
113
  "@semantic-release/exec": "^7.1.0",
97
114
  "@semantic-release/git": "^11.0.0",
@@ -100,14 +117,17 @@
100
117
  "@semantic-release/release-notes-generator": "^14.1.1",
101
118
  "@stryker-mutator/core": "^10.0.0",
102
119
  "@stryker-mutator/vitest-runner": "^10.0.0",
103
- "@tsconfig/node20": "^20.1.9",
120
+ "@tsconfig/node22": "^22.0.6",
104
121
  "@tsconfig/strictest": "^2.0.8",
105
122
  "@types/hast": "^3.0.4",
106
123
  "@types/node": "^22.15.0",
124
+ "@types/oidc-provider": "9.12.1",
107
125
  "@vitest/coverage-v8": "^5.0.0",
108
126
  "conventional-changelog-conventionalcommits": "^10.2.1",
127
+ "jose": "6.2.12",
109
128
  "lefthook": "^2.1.10",
110
129
  "msw": "^2.12.13",
130
+ "oidc-provider": "~9.12.2",
111
131
  "semantic-release": "^25.0.3",
112
132
  "shx": "^0.4.0",
113
133
  "tsdown": "^0.23.0",