@fruggr/zendesk-mcp-server 3.4.0 → 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.
- package/README.md +6 -3
- package/dist/THIRD-PARTY-NOTICES.md +1 -1
- package/dist/{http-DU1dSrPe.js → http-DDdso3Av.js} +31 -5
- package/dist/index.js +2048 -250
- package/dist/pkce-DE_v9NQy.js +60 -0
- package/dist/sbom.cdx.json +7 -7
- package/dist/{token-store-DyO12sbo.js → token-store-CQx_KFe2.js} +16 -7
- package/package.json +4 -4
- package/dist/token-persistence-r0v9RO3q.js +0 -147
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 };
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.6",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
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.
|
|
9
|
+
"bom-ref": "pkg:npm/%40fruggr/zendesk-mcp-server@3.5.0",
|
|
10
10
|
"name": "@fruggr/zendesk-mcp-server",
|
|
11
|
-
"version": "3.
|
|
12
|
-
"purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.
|
|
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.
|
|
2118
|
+
"bom-ref": "pkg:npm/zod@4.6.7",
|
|
2119
2119
|
"name": "zod",
|
|
2120
|
-
"version": "4.6.
|
|
2121
|
-
"purl": "pkg:npm/zod@4.6.
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
918
|
-
|
|
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
|
+
"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,7 +72,7 @@
|
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=22"
|
|
74
74
|
},
|
|
75
|
-
"packageManager": "pnpm@12.
|
|
75
|
+
"packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6",
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@biomejs/biome": "2.5.15",
|
|
78
78
|
"@modelcontextprotocol/client": "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.
|
|
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.
|
|
120
|
+
"zod": "4.6.7"
|
|
121
121
|
}
|
|
122
122
|
}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { a as silentLogger, i as readPackageInfo, x as readEnv } 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 (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 };
|