@fruggr/zendesk-mcp-server 3.3.1 → 3.5.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.
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ //#region src/utils/html.ts
4
+ /**
5
+ * Escape a string for safe interpolation into HTML text/attribute context.
6
+ * Both the stdio callback page and the HTTP consent page echo
7
+ * attacker-controllable values (an OAuth `error_description`, a client name
8
+ * from a fetched metadata document); without escaping these are an XSS sink.
9
+ */
10
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11
+ //#endregion
12
+ //#region src/auth/oauth-scopes.ts
13
+ /**
14
+ * The OAuth scope the server asks Zendesk for, and the one predicate that
15
+ * decides whether a cached token's grant is still good enough.
16
+ *
17
+ * Both scope strings live here rather than in `constants.ts` on purpose: they
18
+ * are behavioural strings whose mutants must be killed by assertions
19
+ * (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
20
+ * `src/auth/**` is inside the mutation scope while `constants.ts` is not.
21
+ * Keeping them next to the predicate that consumes them keeps both under the
22
+ * same gate.
23
+ */
24
+ const READ_SCOPE = "read";
25
+ const READ_WRITE_SCOPE = "read write";
26
+ const WHITESPACE = /\s+/;
27
+ const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
28
+ /**
29
+ * The scope to request for a given tool surface. `--read-only` already filters
30
+ * every write tool out of the surface, so asking Zendesk for `write` on top of
31
+ * that would be requesting an authority the server cannot even exercise — and
32
+ * an OAuth client whose allowed scopes stop at `read` rejects the whole
33
+ * authorize request with `invalid_scope`, minting no token at all (#283).
34
+ */
35
+ const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
36
+ /**
37
+ * The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
38
+ * metadata. Derived from `requestedScope` so what the HTTP transport advertises
39
+ * provably cannot drift from what the stdio flow requests.
40
+ */
41
+ const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
42
+ /**
43
+ * Whether a grant still covers what this process needs: a flat subset test.
44
+ * Coverage, not equality, so a token Zendesk granted wider than requested stays
45
+ * usable. A non-string `granted` is a pre-#283 record, i.e. `read write` — only
46
+ * reachable now through a `OAUTH_TOKEN_FILE` aimed at one by hand, since the
47
+ * default layout no longer names those files. No scope hierarchy: granular
48
+ * scopes (#284) replace this.
49
+ */
50
+ const grantCovers = (granted, requested) => {
51
+ if (typeof granted !== "string") return true;
52
+ const held = new Set(scopeTokens(granted));
53
+ return scopeTokens(requested).every((token) => held.has(token));
54
+ };
55
+ //#endregion
56
+ //#region src/auth/pkce.ts
57
+ const generateCodeVerifier = () => randomBytes(32).toString("base64url");
58
+ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
59
+ //#endregion
60
+ export { supportedScopes as a, requestedScope as i, generateCodeVerifier as n, escapeHtml as o, grantCovers as r, generateCodeChallenge as t };
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:e7c0861b-27d8-53fc-9aa5-7312e5af7ead",
4
+ "serialNumber": "urn:uuid:4bd8e289-8e88-51ac-9e9e-efc17ba1d8a7",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "application",
9
- "bom-ref": "pkg:npm/%40fruggr/zendesk-mcp-server@3.3.1",
9
+ "bom-ref": "pkg:npm/%40fruggr/zendesk-mcp-server@3.5.0",
10
10
  "name": "@fruggr/zendesk-mcp-server",
11
- "version": "3.3.1",
12
- "purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.3.1"
11
+ "version": "3.5.0",
12
+ "purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.5.0"
13
13
  }
14
14
  },
15
15
  "components": [
@@ -2115,10 +2115,10 @@
2115
2115
  },
2116
2116
  {
2117
2117
  "type": "library",
2118
- "bom-ref": "pkg:npm/zod@4.6.5",
2118
+ "bom-ref": "pkg:npm/zod@4.6.7",
2119
2119
  "name": "zod",
2120
- "version": "4.6.5",
2121
- "purl": "pkg:npm/zod@4.6.5",
2120
+ "version": "4.6.7",
2121
+ "purl": "pkg:npm/zod@4.6.7",
2122
2122
  "licenses": [
2123
2123
  {
2124
2124
  "expression": "MIT"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { f as getOAuthUrls, r as silentLogger } from "./index.js";
3
- import { a as saveToken, c as grantCovers, d as escapeHtml, i as resolveTokenPath, l as requestedScope, o as generateCodeChallenge, r as loadToken, s as generateCodeVerifier, t as clearToken } from "./token-persistence-BUHh23BM.js";
2
+ import { d as saveToken, f as signInRequiredError, l as loadToken, m as silentLogger, s as clearToken, u as resolveTokenPath, y as getOAuthUrls } from "./index.js";
3
+ import { i as requestedScope, n as generateCodeVerifier, o as escapeHtml, r as grantCovers, t as generateCodeChallenge } from "./pkce-DE_v9NQy.js";
4
4
  import { promisify } from "node:util";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -510,7 +510,7 @@ const baseOpen = async (options) => {
510
510
  });
511
511
  });
512
512
  };
513
- const open = (target, options) => {
513
+ const open$1 = (target, options) => {
514
514
  if (typeof target !== "string") throw new TypeError("Expected a `target`");
515
515
  return baseOpen({
516
516
  ...options,
@@ -736,7 +736,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
736
736
  logger.debug("oauth_authorize_url", { url: authUrl });
737
737
  console.error(`Opening browser for Zendesk authentication...`);
738
738
  console.error(`If the browser doesn't open, visit: ${authUrl}`);
739
- open(authUrl).then(() => {
739
+ open$1(authUrl).then(() => {
740
740
  logger.debug("oauth_browser_opened");
741
741
  }).catch((err) => {
742
742
  logger.error("oauth_browser_open_failed", {
@@ -908,14 +908,17 @@ const createTokenStore = (config, logger = silentLogger) => {
908
908
  });
909
909
  return refreshing;
910
910
  };
911
- const getToken = async () => {
911
+ const tokenWithoutPrompt = async () => {
912
912
  if (refreshing !== void 0) await refreshing;
913
913
  if (token && !needsRefresh(token)) {
914
914
  logger.debug("oauth_token_cache_hit");
915
915
  return token.accessToken;
916
916
  }
917
- const refreshed = await refreshIfPossible();
918
- if (refreshed) return refreshed;
917
+ return refreshIfPossible();
918
+ };
919
+ const getToken = async () => {
920
+ const silent = await tokenWithoutPrompt();
921
+ if (silent) return silent;
919
922
  if (starting === void 0) starting = beginAuth();
920
923
  const url = authorizeUrl ?? await starting;
921
924
  throw createAuthRequiredError(url);
@@ -942,8 +945,14 @@ const createTokenStore = (config, logger = silentLogger) => {
942
945
  }, SCHEDULED_REFRESH_MS);
943
946
  scheduledRefresh.unref?.();
944
947
  const dispose = () => clearInterval(scheduledRefresh);
948
+ const getTokenSilently = async () => {
949
+ const silent = await tokenWithoutPrompt();
950
+ if (silent) return silent;
951
+ throw signInRequiredError();
952
+ };
945
953
  return {
946
954
  getToken,
955
+ getTokenSilently,
947
956
  setToken,
948
957
  invalidate,
949
958
  dispose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "3.3.1",
3
+ "version": "3.5.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",
@@ -72,9 +72,9 @@
72
72
  "engines": {
73
73
  "node": ">=22"
74
74
  },
75
- "packageManager": "pnpm@12.11.1+sha512.5b0e3288239a7ea4155d2094f547066e122a1376b3cd785a680c9112e116913e64aaaecf20dd83cc34030fa67013f5c5511f9cf430dd8384188b4c60b8a2ad1e",
75
+ "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6",
76
76
  "devDependencies": {
77
- "@biomejs/biome": "2.5.14",
77
+ "@biomejs/biome": "2.5.15",
78
78
  "@modelcontextprotocol/client": "2.3.1",
79
79
  "@modelcontextprotocol/node": "2.1.1",
80
80
  "@modelcontextprotocol/server": "2.3.1",
@@ -97,7 +97,7 @@
97
97
  "hast-util-to-html": "9.0.5",
98
98
  "hast-util-to-mdast": "10.1.2",
99
99
  "jose": "6.2.12",
100
- "lefthook": "^2.1.10",
100
+ "lefthook": "^2.2.2",
101
101
  "msw": "^3.0.0",
102
102
  "oidc-provider": "~9.12.2",
103
103
  "open": "11.0.4",
@@ -117,6 +117,6 @@
117
117
  "typescript-legacy": "npm:typescript@6.0.3",
118
118
  "unified": "11.0.5",
119
119
  "vitest": "^5.0.0",
120
- "zod": "4.6.5"
120
+ "zod": "4.6.7"
121
121
  }
122
122
  }
@@ -1,147 +0,0 @@
1
- #!/usr/bin/env node
2
- import { g as readEnv, n as readPackageInfo, r as silentLogger } from "./index.js";
3
- import { createHash, randomBytes } from "node:crypto";
4
- import { dirname, join } from "node:path";
5
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- //#region src/utils/html.ts
8
- /**
9
- * Escape a string for safe interpolation into HTML text/attribute context.
10
- * Both the stdio callback page and the HTTP consent page echo
11
- * attacker-controllable values (an OAuth `error_description`, a client name
12
- * from a fetched metadata document); without escaping these are an XSS sink.
13
- */
14
- const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15
- //#endregion
16
- //#region src/auth/oauth-scopes.ts
17
- /**
18
- * The OAuth scope the server asks Zendesk for, and the one predicate that
19
- * decides whether a cached token's grant is still good enough.
20
- *
21
- * Both scope strings live here rather than in `constants.ts` on purpose: they
22
- * are behavioural strings whose mutants must be killed by assertions
23
- * (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
24
- * `src/auth/**` is inside the mutation scope while `constants.ts` is not.
25
- * Keeping them next to the predicate that consumes them keeps both under the
26
- * same gate.
27
- */
28
- const READ_SCOPE = "read";
29
- const READ_WRITE_SCOPE = "read write";
30
- const WHITESPACE = /\s+/;
31
- const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
32
- /**
33
- * The scope to request for a given tool surface. `--read-only` already filters
34
- * every write tool out of the surface, so asking Zendesk for `write` on top of
35
- * that would be requesting an authority the server cannot even exercise — and
36
- * an OAuth client whose allowed scopes stop at `read` rejects the whole
37
- * authorize request with `invalid_scope`, minting no token at all (#283).
38
- */
39
- const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
40
- /**
41
- * The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
42
- * metadata. Derived from `requestedScope` so what the HTTP transport advertises
43
- * provably cannot drift from what the stdio flow requests.
44
- */
45
- const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
46
- /**
47
- * Whether a grant still covers what this process needs: a flat subset test.
48
- * Coverage, not equality, so a token Zendesk granted wider than requested stays
49
- * usable. A non-string `granted` is a pre-#283 record, i.e. `read write` — only
50
- * reachable now through a `OAUTH_TOKEN_FILE` aimed at one by hand, since the
51
- * default layout no longer names those files. No scope hierarchy: granular
52
- * scopes (#284) replace this.
53
- */
54
- const grantCovers = (granted, requested) => {
55
- if (typeof granted !== "string") return true;
56
- const held = new Set(scopeTokens(granted));
57
- return scopeTokens(requested).every((token) => held.has(token));
58
- };
59
- //#endregion
60
- //#region src/auth/pkce.ts
61
- const generateCodeVerifier = () => randomBytes(32).toString("base64url");
62
- const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
63
- //#endregion
64
- //#region src/auth/token-persistence.ts
65
- const isWindows = process.platform === "win32";
66
- /**
67
- * Config-dir segments derived from the *scoped* package name
68
- * (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
69
- * vendor-namespaced and can't collide with another `zendesk-mcp-server`.
70
- */
71
- const SCOPED_PACKAGE_NAME = /^@([^/]+)\/(.+)$/;
72
- const appDirSegments = () => {
73
- const { name } = readPackageInfo();
74
- const scoped = SCOPED_PACKAGE_NAME.exec(name);
75
- return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
76
- };
77
- const configDir = () => {
78
- const segments = appDirSegments();
79
- if (isWindows) {
80
- const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
81
- return join(base, ...segments);
82
- }
83
- const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
84
- return join(base, ...segments);
85
- };
86
- const safeName = (part) => part.replace(/[^a-z0-9-]/gi, "_");
87
- const READABLE_BUDGET = 120;
88
- const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
89
- key.subdomain,
90
- key.oauthClientId,
91
- key.scope
92
- ])).digest("hex").slice(0, 8);
93
- /**
94
- * Path to the token file for one credential. Keyed on the whole `TokenKey`, so
95
- * a read-only server, a read-write one and one on another OAuth client each get
96
- * their own record instead of clobbering a shared file. Why that triple, and
97
- * why no migration from the old subdomain-only layout:
98
- * `docs/decisions/token-file-keying.md`.
99
- * `OAUTH_TOKEN_FILE` overrides with an explicit path — the way to separate
100
- * two Zendesk accounts that share a subdomain, client and scope.
101
- */
102
- const resolveTokenPath = (key) => {
103
- const { name, value: override } = readEnv("OAUTH_TOKEN_FILE");
104
- if (override === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
105
- if (override) return override;
106
- const readable = [
107
- key.subdomain,
108
- key.oauthClientId,
109
- key.scope
110
- ].map(safeName).join("--").slice(0, READABLE_BUDGET);
111
- return join(configDir(), `${readable}--${keyDigest(key)}.json`);
112
- };
113
- const writeFileAtomic = (path, record) => {
114
- const dir = dirname(path);
115
- mkdirSync(dir, { recursive: true });
116
- const tmp = `${path}.${process.pid}.tmp`;
117
- writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
118
- if (!isWindows) chmodSync(tmp, 384);
119
- renameSync(tmp, path);
120
- if (!isWindows) try {
121
- chmodSync(dir, 448);
122
- } catch {}
123
- };
124
- const loadToken = (path) => {
125
- try {
126
- const raw = JSON.parse(readFileSync(path, "utf8"));
127
- if (raw && typeof raw === "object" && typeof raw.accessToken === "string") return raw;
128
- } catch {}
129
- };
130
- const saveToken = (path, record, logger = silentLogger) => {
131
- try {
132
- writeFileAtomic(path, record);
133
- logger.debug("token_persisted");
134
- } catch (err) {
135
- logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
136
- }
137
- };
138
- const clearToken = (path, logger = silentLogger) => {
139
- try {
140
- rmSync(path, { force: true });
141
- logger.debug("token_cleared");
142
- } catch (err) {
143
- logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
144
- }
145
- };
146
- //#endregion
147
- export { saveToken as a, grantCovers as c, escapeHtml as d, resolveTokenPath as i, requestedScope as l, configDir as n, generateCodeChallenge as o, loadToken as r, generateCodeVerifier as s, clearToken as t, supportedScopes as u };