@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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +77 -11
  3. 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: "read write",
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) logger.debug("oauth_token_loaded_from_disk");
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: ["read", "write"]
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: ["read", "write"]
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.20.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",