@fruggr/zendesk-mcp-server 2.20.2 → 2.21.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 +4 -0
- package/dist/index.js +77 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,10 @@ Signing in needs a Zendesk OAuth client, so register one first (next section).
|
|
|
126
126
|
`ZENDESK_OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
|
|
127
127
|
accepts several redirect URLs, one per line)
|
|
128
128
|
|
|
129
|
+
If the client restricts its **allowed scopes**, it needs `read` — plus `write`
|
|
130
|
+
unless the server runs with [`--read-only`](docs/configuration.md), which asks
|
|
131
|
+
Zendesk for the `read` scope alone.
|
|
132
|
+
|
|
129
133
|
On the first tool call the server starts the sign-in flow: it opens a browser
|
|
130
134
|
window and returns the authorize URL in a tool message. The call does not block
|
|
131
135
|
waiting for sign-in, so authenticate in the browser and then retry the request.
|
package/dist/index.js
CHANGED
|
@@ -143,6 +143,48 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
143
143
|
tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
|
|
144
144
|
});
|
|
145
145
|
//#endregion
|
|
146
|
+
//#region src/auth/oauth-scopes.ts
|
|
147
|
+
/**
|
|
148
|
+
* The OAuth scope the server asks Zendesk for, and the one predicate that
|
|
149
|
+
* decides whether a cached token's grant is still good enough.
|
|
150
|
+
*
|
|
151
|
+
* Both scope strings live here rather than in `constants.ts` on purpose: they
|
|
152
|
+
* are behavioural strings whose mutants must be killed by assertions
|
|
153
|
+
* (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
|
|
154
|
+
* `src/auth/**` is inside the mutation scope while `constants.ts` is not.
|
|
155
|
+
* Keeping them next to the predicate that consumes them keeps both under the
|
|
156
|
+
* same gate.
|
|
157
|
+
*/
|
|
158
|
+
const READ_SCOPE = "read";
|
|
159
|
+
const READ_WRITE_SCOPE = "read write";
|
|
160
|
+
const WHITESPACE = /\s+/;
|
|
161
|
+
const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
|
|
162
|
+
/**
|
|
163
|
+
* The scope to request for a given tool surface. `--read-only` already filters
|
|
164
|
+
* every write tool out of the surface, so asking Zendesk for `write` on top of
|
|
165
|
+
* that would be requesting an authority the server cannot even exercise — and
|
|
166
|
+
* an OAuth client whose allowed scopes stop at `read` rejects the whole
|
|
167
|
+
* authorize request with `invalid_scope`, minting no token at all (#283).
|
|
168
|
+
*/
|
|
169
|
+
const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
|
|
170
|
+
/**
|
|
171
|
+
* The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
|
|
172
|
+
* metadata. Derived from `requestedScope` so what the HTTP transport advertises
|
|
173
|
+
* provably cannot drift from what the stdio flow requests.
|
|
174
|
+
*/
|
|
175
|
+
const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
|
|
176
|
+
/**
|
|
177
|
+
* Whether a grant still covers what this process needs: a flat subset test.
|
|
178
|
+
* Coverage, not equality, so a broader token stays usable and two servers
|
|
179
|
+
* sharing a token file converge. A non-string `granted` is a pre-#283 record,
|
|
180
|
+
* i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
|
|
181
|
+
*/
|
|
182
|
+
const grantCovers = (granted, requested) => {
|
|
183
|
+
if (typeof granted !== "string") return true;
|
|
184
|
+
const held = new Set(scopeTokens(granted));
|
|
185
|
+
return scopeTokens(requested).every((token) => held.has(token));
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
146
188
|
//#region src/auth/browser-oauth.ts
|
|
147
189
|
const AUTH_TIMEOUT_MS = 3e5;
|
|
148
190
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
@@ -185,7 +227,7 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
|
|
|
185
227
|
* open for up to the 5-minute timeout.
|
|
186
228
|
*/
|
|
187
229
|
const startBrowserAuth = (config, logger = silentLogger) => {
|
|
188
|
-
const { subdomain, oauthClientId } = config;
|
|
230
|
+
const { subdomain, oauthClientId, readOnly } = config;
|
|
189
231
|
const { authorizeUrl: authorizeBase, tokenUrl } = getOAuthUrls(subdomain);
|
|
190
232
|
const codeVerifier = generateCodeVerifier();
|
|
191
233
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
@@ -308,7 +350,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
308
350
|
response_type: "code",
|
|
309
351
|
client_id: oauthClientId,
|
|
310
352
|
redirect_uri: redirectUri,
|
|
311
|
-
scope:
|
|
353
|
+
scope: requestedScope(readOnly),
|
|
312
354
|
code_challenge: codeChallenge,
|
|
313
355
|
code_challenge_method: "S256"
|
|
314
356
|
});
|
|
@@ -497,8 +539,18 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
|
|
|
497
539
|
const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
|
|
498
540
|
const createTokenStore = (config, logger = silentLogger) => {
|
|
499
541
|
const tokenPath = resolveTokenPath(config.subdomain);
|
|
542
|
+
const requested = requestedScope(config.readOnly);
|
|
500
543
|
let token = loadToken(tokenPath);
|
|
501
|
-
if (token)
|
|
544
|
+
if (token) {
|
|
545
|
+
if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
|
|
546
|
+
else {
|
|
547
|
+
logger.warn("oauth_token_scope_insufficient", {
|
|
548
|
+
requested,
|
|
549
|
+
granted: token.scope
|
|
550
|
+
});
|
|
551
|
+
token = void 0;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
502
554
|
let authorizeUrl;
|
|
503
555
|
let starting;
|
|
504
556
|
let refreshing;
|
|
@@ -507,12 +559,20 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
507
559
|
const setToken = (accessToken, refreshToken) => {
|
|
508
560
|
token = {
|
|
509
561
|
accessToken,
|
|
510
|
-
refreshToken
|
|
562
|
+
refreshToken,
|
|
563
|
+
scope: requested
|
|
511
564
|
};
|
|
512
565
|
probedUnknownExpiry = false;
|
|
513
566
|
persist(token);
|
|
514
567
|
};
|
|
515
568
|
const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
|
|
569
|
+
const noteGrant = (granted) => {
|
|
570
|
+
if (!grantCovers(granted, requested)) logger.warn("oauth_token_grant_narrowed", {
|
|
571
|
+
requested,
|
|
572
|
+
granted
|
|
573
|
+
});
|
|
574
|
+
return granted;
|
|
575
|
+
};
|
|
516
576
|
const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
|
|
517
577
|
if (!current.refreshToken) return void 0;
|
|
518
578
|
try {
|
|
@@ -524,7 +584,8 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
524
584
|
token = {
|
|
525
585
|
accessToken: result.access_token,
|
|
526
586
|
refreshToken: result.refresh_token ?? current.refreshToken,
|
|
527
|
-
expiresAt: expiryFrom(result.expires_in)
|
|
587
|
+
expiresAt: expiryFrom(result.expires_in),
|
|
588
|
+
scope: noteGrant(result.scope || current.scope)
|
|
528
589
|
};
|
|
529
590
|
probedUnknownExpiry = true;
|
|
530
591
|
persist(token);
|
|
@@ -544,14 +605,16 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
544
605
|
return startBrowserAuth({
|
|
545
606
|
subdomain: config.subdomain,
|
|
546
607
|
oauthClientId: config.oauthClientId,
|
|
547
|
-
callbackPort: config.callbackPort
|
|
608
|
+
callbackPort: config.callbackPort,
|
|
609
|
+
readOnly: config.readOnly
|
|
548
610
|
}, logger).then((started) => {
|
|
549
611
|
authorizeUrl = started.authorizeUrl;
|
|
550
612
|
started.tokenPromise.then((result) => {
|
|
551
613
|
token = {
|
|
552
614
|
accessToken: result.access_token,
|
|
553
615
|
refreshToken: result.refresh_token,
|
|
554
|
-
expiresAt: expiryFrom(result.expires_in)
|
|
616
|
+
expiresAt: expiryFrom(result.expires_in),
|
|
617
|
+
scope: noteGrant(result.scope || requested)
|
|
555
618
|
};
|
|
556
619
|
probedUnknownExpiry = true;
|
|
557
620
|
persist(token);
|
|
@@ -593,7 +656,8 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
593
656
|
token = {
|
|
594
657
|
accessToken: token.accessToken,
|
|
595
658
|
refreshToken: token.refreshToken,
|
|
596
|
-
expiresAt: 0
|
|
659
|
+
expiresAt: 0,
|
|
660
|
+
scope: token.scope
|
|
597
661
|
};
|
|
598
662
|
persist(token);
|
|
599
663
|
} else {
|
|
@@ -4791,12 +4855,13 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
|
4791
4855
|
const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
|
|
4792
4856
|
const issuer = `https://${config.subdomain}.zendesk.com`;
|
|
4793
4857
|
const resource = resolveResourceUrl(config, logger);
|
|
4858
|
+
const scopes = () => supportedScopes(config.readOnly);
|
|
4794
4859
|
return {
|
|
4795
4860
|
protectedResource: {
|
|
4796
4861
|
authorization_servers: [issuer],
|
|
4797
4862
|
resource,
|
|
4798
4863
|
bearer_methods_supported: ["header"],
|
|
4799
|
-
scopes_supported:
|
|
4864
|
+
scopes_supported: scopes()
|
|
4800
4865
|
},
|
|
4801
4866
|
authorizationServer: {
|
|
4802
4867
|
issuer,
|
|
@@ -4806,7 +4871,7 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
|
4806
4871
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
4807
4872
|
code_challenge_methods_supported: ["S256"],
|
|
4808
4873
|
token_endpoint_auth_methods_supported: ["none"],
|
|
4809
|
-
scopes_supported:
|
|
4874
|
+
scopes_supported: scopes()
|
|
4810
4875
|
}
|
|
4811
4876
|
};
|
|
4812
4877
|
};
|
|
@@ -5129,7 +5194,8 @@ const installShutdown = (options) => {
|
|
|
5129
5194
|
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
5130
5195
|
subdomain: config.subdomain,
|
|
5131
5196
|
oauthClientId: config.oauthClientId,
|
|
5132
|
-
callbackPort: config.callbackPort
|
|
5197
|
+
callbackPort: config.callbackPort,
|
|
5198
|
+
readOnly: config.readOnly
|
|
5133
5199
|
}, logger);
|
|
5134
5200
|
const connectStdio = async (config, tokenStore, logger) => {
|
|
5135
5201
|
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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",
|