@fruggr/zendesk-mcp-server 3.0.3 → 3.1.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.
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import { n as readPackageInfo, p as readEnv, 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 };