@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.
- package/README.md +4 -3
- package/dist/THIRD-PARTY-NOTICES.md +5272 -0
- package/dist/http-CmtU-oNu.js +36570 -0
- package/dist/index.js +102811 -1489
- package/dist/sbom.cdx.json +2152 -0
- package/dist/token-persistence-BeetFqPF.js +147 -0
- package/dist/token-store-CLtwRaxI.js +972 -0
- package/package.json +18 -35
- package/dist/http-BXXDmcaZ.js +0 -1383
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 };
|