brightspace-mcp-server 5.1.18 → 5.1.20

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.
@@ -7,7 +7,7 @@ import { DEFAULT_CACHE_TTLS } from "./types.js";
7
7
  import { TTLCache } from "./cache.js";
8
8
  import { TokenBucket } from "./rate-limiter.js";
9
9
  import { discoverVersions } from "./version-discovery.js";
10
- import { ApiError, RateLimitError, NetworkError } from "./errors.js";
10
+ import { ApiError, CredentialRejectedError, RateLimitError, NetworkError } from "./errors.js";
11
11
  import { withRetry, isRetryableFailure, retryAfterMsFrom, parseRetryAfter, } from "./retry.js";
12
12
  import { log } from "../utils/logger.js";
13
13
  import { AUTH_COMMAND } from "../utils/commands.js";
@@ -475,7 +475,9 @@ export class D2LApiClient {
475
475
  }
476
476
  log("WARN", "Auto-reauthentication did not produce a valid token");
477
477
  }
478
- throw new ApiError(401, path, this.authExpiredMessage ?? `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
478
+ if (this.authExpiredMessage)
479
+ throw new CredentialRejectedError(path, this.authExpiredMessage);
480
+ throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
479
481
  }
480
482
  /**
481
483
  * Make one HTTP request. Authentication recovery is shared with raw downloads.
@@ -17,6 +17,19 @@ export class ApiError extends AuthError {
17
17
  this.name = "ApiError";
18
18
  }
19
19
  }
20
+ /**
21
+ * A 401 for a credential pasted into the environment (D2L_ACCESS_TOKEN or
22
+ * D2L_SESSION_COOKIE). No browser sign-in stands behind it, so the user is
23
+ * told how to replace the credential rather than to run the auth command.
24
+ */
25
+ export class CredentialRejectedError extends ApiError {
26
+ guidance;
27
+ constructor(endpoint, guidance) {
28
+ super(401, endpoint, guidance);
29
+ this.guidance = guidance;
30
+ this.name = "CredentialRejectedError";
31
+ }
32
+ }
20
33
  // Generic HTTP error (non-401/429) - semantic alias for ApiError
21
34
  export class HttpError extends ApiError {
22
35
  constructor(status, endpoint, message, responseBody, cause) {
@@ -12,5 +12,5 @@ export { discoverVersions } from "./version-discovery.js";
12
12
  export { TTLCache } from "./cache.js";
13
13
  export { TokenBucket } from "./rate-limiter.js";
14
14
  // Errors
15
- export { ApiError, RateLimitError, NetworkError } from "./errors.js";
15
+ export { ApiError, CredentialRejectedError, RateLimitError, NetworkError } from "./errors.js";
16
16
  export { DEFAULT_CACHE_TTLS } from "./types.js";
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { SessionStore } from "./session-store.js";
7
7
  import { mintAccessToken } from "./token-mint.js";
8
+ import { fetchXsrfToken } from "./xsrf-fetch.js";
8
9
  import { log } from "../utils/logger.js";
9
10
  import { TokenRefreshError } from "../api/errors.js";
10
11
  import { devActivity } from "../utils/dev-activity.js";
@@ -15,6 +16,8 @@ import { devActivity } from "../utils/dev-activity.js";
15
16
  const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
16
17
  /** Matches the tokenTtl default in the app config. */
17
18
  const DEFAULT_TOKEN_TTL_SECONDS = 3600;
19
+ /** How long a D2L_SESSION_COOKIE mint that failed in transit waits before trying again. */
20
+ const ENV_MINT_RETRY_MS = 60 * 1000;
18
21
  /**
19
22
  * TokenManager manages token lifecycle with in-memory caching and disk persistence.
20
23
  * Handles expiry detection with a configurable refresh buffer.
@@ -25,8 +28,18 @@ export class TokenManager {
25
28
  baseUrl;
26
29
  tokenTtl;
27
30
  mint;
31
+ fetchXsrf;
28
32
  envAccessToken;
29
33
  envSessionCookie;
34
+ mintEnvCookie;
35
+ /** The Bearer JWT minted from envSessionCookie, memory only. */
36
+ envMinted = null;
37
+ /** The XSRF token that last minted successfully, reused for the hourly re-mint. */
38
+ envCsrfToken;
39
+ /** Set once the env cookie is known not to mint, so later calls skip straight to the passthrough. */
40
+ envMintDisabled = false;
41
+ envMintRetryAt = 0;
42
+ envMintInFlight = null;
30
43
  /** Single in-flight mint, so concurrent callers share one request. */
31
44
  mintInFlight = null;
32
45
  rejectedAccessToken = null;
@@ -53,8 +66,10 @@ export class TokenManager {
53
66
  this.baseUrl = options.baseUrl ? new URL(options.baseUrl).origin : undefined;
54
67
  this.tokenTtl = options.tokenTtl ?? DEFAULT_TOKEN_TTL_SECONDS;
55
68
  this.mint = options.mint ?? mintAccessToken;
69
+ this.fetchXsrf = options.fetchXsrf ?? fetchXsrfToken;
56
70
  this.envAccessToken = options.envAccessToken;
57
71
  this.envSessionCookie = options.envSessionCookie;
72
+ this.mintEnvCookie = options.mintEnvCookie ?? false;
58
73
  }
59
74
  /**
60
75
  * Get the current token if valid, otherwise null.
@@ -72,6 +87,11 @@ export class TokenManager {
72
87
  // value again. (See D2LApiClient.tryAutoReauth / authExpiredMessage for
73
88
  // the user-facing "paste a fresh one" error this produces.)
74
89
  if (this.envAccessToken || this.envSessionCookie) {
90
+ if (!this.envAccessToken && this.mintEnvCookie && this.baseUrl) {
91
+ const minted = await this.envCookieBearer(rejectedAccessToken);
92
+ if (minted)
93
+ return minted;
94
+ }
75
95
  const token = this.buildEnvToken();
76
96
  if (rejectedAccessToken && token.accessToken === rejectedAccessToken) {
77
97
  log("DEBUG", "The env-provided credential was rejected; no replacement is available");
@@ -255,6 +275,75 @@ export class TokenManager {
255
275
  cookieHeader: this.envSessionCookie,
256
276
  };
257
277
  }
278
+ /**
279
+ * The Bearer JWT minted from envSessionCookie, minting one when there is
280
+ * none or it expired or was just rejected. Null means use the cookie
281
+ * passthrough instead, which also keeps the existing "paste a fresh one"
282
+ * error for a dead cookie.
283
+ */
284
+ async envCookieBearer(rejectedAccessToken) {
285
+ if (this.envMinted && rejectedAccessToken === this.envMinted.accessToken) {
286
+ log("DEBUG", "The JWT minted from D2L_SESSION_COOKIE was rejected; minting again");
287
+ this.envMinted = null;
288
+ }
289
+ if (this.envMinted && this.isValid(this.envMinted))
290
+ return this.envMinted;
291
+ if (this.envMintDisabled || Date.now() < this.envMintRetryAt)
292
+ return null;
293
+ this.envMintInFlight ??= this.runEnvMint().finally(() => {
294
+ this.envMintInFlight = null;
295
+ });
296
+ return this.envMintInFlight;
297
+ }
298
+ async runEnvMint() {
299
+ const baseUrl = this.baseUrl;
300
+ const cookieHeader = this.envSessionCookie;
301
+ let csrfToken = this.envCsrfToken;
302
+ if (!csrfToken) {
303
+ const xsrf = await this.fetchXsrf({ baseUrl, cookieHeader });
304
+ if (!xsrf.ok)
305
+ return this.envMintFailed(xsrf.reason, xsrf.detail);
306
+ csrfToken = xsrf.csrfToken;
307
+ }
308
+ let result;
309
+ try {
310
+ result = await this.mint({ baseUrl, cookieHeader, csrfToken });
311
+ }
312
+ catch (error) {
313
+ return this.envMintFailed("transport", error instanceof Error ? error.message : String(error));
314
+ }
315
+ if (!result.ok) {
316
+ // A stale XSRF token is one cause of a refused mint; read a fresh one next time.
317
+ this.envCsrfToken = undefined;
318
+ return this.envMintFailed(result.reason, result.detail);
319
+ }
320
+ const now = Date.now();
321
+ this.envCsrfToken = csrfToken;
322
+ this.envMinted = {
323
+ accessToken: result.accessToken,
324
+ tenantOrigin: this.baseUrl,
325
+ capturedAt: now,
326
+ expiresAt: now + this.tokenTtl * 1000,
327
+ source: "env",
328
+ cookieHeader,
329
+ };
330
+ log("INFO", "Minted a Bearer token from D2L_SESSION_COOKIE without a browser");
331
+ return this.envMinted;
332
+ }
333
+ /**
334
+ * A dead cookie or a page with no XSRF token will not mint later either, so
335
+ * stop trying; a transport failure is retried after ENV_MINT_RETRY_MS.
336
+ */
337
+ envMintFailed(reason, detail) {
338
+ if (reason === "transport") {
339
+ this.envMintRetryAt = Date.now() + ENV_MINT_RETRY_MS;
340
+ }
341
+ else {
342
+ this.envMintDisabled = true;
343
+ }
344
+ log("DEBUG", `Could not mint from D2L_SESSION_COOKIE (${reason}${detail ? `: ${detail}` : ""}); using the cookie as-is`);
345
+ return null;
346
+ }
258
347
  /**
259
348
  * Only use persisted session material, including updates from other processes.
260
349
  */
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT: see LICENSE file for details.
5
+ */
6
+ import { log } from "../utils/logger.js";
7
+ /** Same marker token-mint.ts keys on: a dead session gets a 200 redirect stub. */
8
+ const EXPIRED_MARKER = "/d2l/login?sessionExpired=1";
9
+ /** Same identity the API client sends, see buildAuthHeaders in api/client.ts. */
10
+ const USER_AGENT = "BrightspaceMCP/1.0 (Rohan Muppa; github.com/rohanmuppa/brightspace-mcp-server)";
11
+ const DEFAULT_TIMEOUT_MS = 15000;
12
+ /**
13
+ * Where a Brightspace page carries its XSRF token, most specific first.
14
+ *
15
+ * The browser path reads D2L.LP.Web.Authentication.Xsrf.GetXsrfToken(), whose
16
+ * value is the one the page also writes to localStorage as "XSRF.Token"
17
+ * (the same 32 characters). Measured against purdue.brightspace.com: the
18
+ * cookie-only HTML carries it in an inline
19
+ * `localStorage.setItem('XSRF.Token','...')`, so that is the first place to
20
+ * look; the meta tag is the browser path's own fallback.
21
+ */
22
+ const XSRF_PATTERNS = [
23
+ /XSRF\.Token\\?["']\s*,\s*\\?["']([A-Za-z0-9_-]{16,})\\?["']/,
24
+ /<meta[^>]*name=["']d2l-xsrf-token["'][^>]*content=["']([A-Za-z0-9_-]{16,})["']/i,
25
+ /<meta[^>]*content=["']([A-Za-z0-9_-]{16,})["'][^>]*name=["']d2l-xsrf-token["']/i,
26
+ ];
27
+ /** Pull the XSRF token out of a Brightspace page's HTML, or null when absent. */
28
+ export function parseXsrfToken(html) {
29
+ for (const pattern of XSRF_PATTERNS) {
30
+ const match = pattern.exec(html);
31
+ if (match)
32
+ return match[1];
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Read the XSRF token the token mint needs from /d2l/home over plain HTTP,
38
+ * using only the session cookies. This is what lets a pasted
39
+ * D2L_SESSION_COOKIE be traded for a Bearer JWT without a browser.
40
+ */
41
+ export async function fetchXsrfToken({ baseUrl, cookieHeader, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, }) {
42
+ const url = `${baseUrl.replace(/\/+$/, "")}/d2l/home`;
43
+ let status;
44
+ let body;
45
+ let location;
46
+ try {
47
+ const response = await fetchImpl(url, {
48
+ method: "GET",
49
+ headers: { cookie: cookieHeader, "User-Agent": USER_AGENT, accept: "text/html" },
50
+ signal: AbortSignal.timeout(timeoutMs),
51
+ redirect: "manual",
52
+ });
53
+ status = response.status;
54
+ body = await response.text();
55
+ location = response.headers?.get("location") ?? null;
56
+ }
57
+ catch (error) {
58
+ return { ok: false, reason: "transport", detail: error instanceof Error ? error.message : String(error) };
59
+ }
60
+ if (status >= 500 || status === 429)
61
+ return { ok: false, reason: "transport", detail: `HTTP ${status}` };
62
+ // A dead session either redirects to the login page or answers 200 with the
63
+ // redirect stub. Either way the cookie is no good for minting.
64
+ if (status === 401 || body.includes(EXPIRED_MARKER) || (status >= 300 && status < 400 && location?.includes("/d2l/login"))) {
65
+ log("DEBUG", "The home page answered as a signed-out session");
66
+ return { ok: false, reason: "sessionExpired" };
67
+ }
68
+ if (status < 200 || status >= 300)
69
+ return { ok: false, reason: "transport", detail: `HTTP ${status}` };
70
+ const csrfToken = parseXsrfToken(body);
71
+ if (!csrfToken) {
72
+ log("DEBUG", "No XSRF token found in the home page HTML");
73
+ return { ok: false, reason: "notFound" };
74
+ }
75
+ return { ok: true, csrfToken };
76
+ }
package/build/server.js CHANGED
@@ -30,6 +30,7 @@ export function createRuntime(config, version) {
30
30
  tokenTtl: config.tokenTtl,
31
31
  envAccessToken: config.envAccessToken,
32
32
  envSessionCookie: config.envSessionCookie,
33
+ mintEnvCookie: true,
33
34
  });
34
35
  // D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
35
36
  // escape hatches (Docker, headless Linux, WSL, hardware-key MFA
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems } from "../api/paginate.js";
8
8
  import { GetAnnouncementsSchema, } from "./schemas.js";
9
- import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
+ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { matchesModifiedSince } from "../utils/modified-since.js";
@@ -150,6 +150,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
150
150
  // answered, so the result says so instead of reporting zero
151
151
  // announcements as if that were a real measurement.
152
152
  if (isAuthUnavailable(error)) {
153
+ throwIfCredentialRejected(error);
153
154
  log("DEBUG", `get_announcements: sign-in pending for course ${courseId}`, error);
154
155
  const notice = "Sign-in to Brightspace is still in progress, so announcements for this course " +
155
156
  `could not be fetched yet. ${authPendingNotice(error)} Call get_announcements again ` +
@@ -250,6 +251,8 @@ export function registerGetAnnouncements(server, apiClient, config) {
250
251
  // always a bare array — a shape change would break every existing
251
252
  // caller — so a pending course adds a second content block carrying
252
253
  // the notice instead of changing content[0].
254
+ if (pending.length > 0)
255
+ throwIfCredentialRejected(pending[0].authError);
253
256
  if (!modifiedSince) {
254
257
  if (pending.length === 0)
255
258
  return arrayPage(announcements, pagination);
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetAssignmentsSchema } from "./schemas.js";
9
- import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
10
10
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
11
11
  import { log } from "../utils/logger.js";
12
12
  import { applyCourseFilter } from "../utils/course-filter.js";
@@ -892,6 +892,7 @@ export function registerGetAssignments(server, apiClient, config) {
892
892
  // keep working — with authPending/notice added for callers that
893
893
  // want to tell "no assignments" apart from "couldn't check".
894
894
  if (isAuthUnavailable(error)) {
895
+ throwIfCredentialRejected(error);
895
896
  log("DEBUG", `get_assignments: sign-in pending for course ${courseId}`, error);
896
897
  return toolResponse({
897
898
  courseId,
@@ -983,6 +984,7 @@ export function registerGetAssignments(server, apiClient, config) {
983
984
  ],
984
985
  };
985
986
  if (pending.length > 0) {
987
+ throwIfCredentialRejected(pending[0].authError);
986
988
  response.authPending = true;
987
989
  response.unavailableCourseIds = pending.map((c) => c.courseId);
988
990
  response.notice =
@@ -4,7 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { GetCalendarEventsSchema } from "./schemas.js";
7
- import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
7
+ import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
8
8
  import { log } from "../utils/logger.js";
9
9
  import { resolveCourses } from "./resolve-courses.js";
10
10
  import { fetchCourseCalendarEvents } from "./calendar-events.js";
@@ -67,6 +67,7 @@ export function registerGetCalendarEvents(server, apiClient, config) {
67
67
  // which names which course ids are unavailable.
68
68
  const notices = [];
69
69
  if (pendingCourseIds.length > 0) {
70
+ throwIfCredentialRejected(firstAuthError);
70
71
  notices.push("Sign-in to Brightspace is still in progress, so calendar events for " +
71
72
  `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
72
73
  `yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetMyGradesSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected, } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
@@ -115,6 +115,7 @@ export function registerGetMyGrades(server, apiClient, config) {
115
115
  // working — with authPending/notice added for callers that want
116
116
  // to tell "no grades" apart from "couldn't check".
117
117
  if (isAuthUnavailable(error)) {
118
+ throwIfCredentialRejected(error);
118
119
  log("DEBUG", `get_my_grades: sign-in pending for course ${courseId}`, error);
119
120
  return toolResponse({
120
121
  courseId,
@@ -260,6 +261,7 @@ export function registerGetMyGrades(server, apiClient, config) {
260
261
  };
261
262
  const notices = [];
262
263
  if (pending.length > 0) {
264
+ throwIfCredentialRejected(pending[0].authError);
263
265
  response.authPending = true;
264
266
  response.unavailableCourseIds = pending.map((c) => c.courseId);
265
267
  notices.push("Sign-in to Brightspace is still in progress, so grades for " +
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetRosterSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  // Purdue-specific role IDs. These are institution-specific values.
12
12
  // If using at another institution, you may need to adjust these.
@@ -80,6 +80,8 @@ export function registerGetRoster(server, apiClient) {
80
80
  // roster that lost its TAs to a pending sign-in should still show
81
81
  // the instructor it already has.
82
82
  authFailure = [instructorResult, taResult].find((r) => r.status === "rejected" && isAuthUnavailable(r.reason));
83
+ if (authFailure)
84
+ throwIfCredentialRejected(authFailure.reason);
83
85
  // Merge results
84
86
  if (instructorResult.status === "fulfilled") {
85
87
  allUsers.push(...instructorResult.value);
@@ -169,6 +171,7 @@ export function registerGetRoster(server, apiClient) {
169
171
  // authPending/notice added for callers that want to tell "empty
170
172
  // roster" apart from "couldn't check".
171
173
  if (isAuthUnavailable(error)) {
174
+ throwIfCredentialRejected(error);
172
175
  log("DEBUG", `get_roster: sign-in pending for course ${courseId}`, error);
173
176
  return toolResponse({
174
177
  courseId,
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
9
- import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
+ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { quizUrl, discussionUrl } from "../utils/deep-links.js";
12
12
  import { assignmentLinkResolver } from "./assignment-links.js";
@@ -242,6 +242,7 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
242
242
  if (pendingCourseIds.length === 0) {
243
243
  return toolResponse(upcoming);
244
244
  }
245
+ throwIfCredentialRejected(firstAuthError);
245
246
  return toolResponseWithNotice(upcoming, "Sign-in to Brightspace is still in progress, so upcoming due dates for " +
246
247
  `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
247
248
  `yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { ZodError } from "zod";
7
7
  import { ApiError, RateLimitError, NetworkError } from "../api/index.js";
8
- import { TokenRefreshError } from "../api/errors.js";
8
+ import { CredentialRejectedError, TokenRefreshError } from "../api/errors.js";
9
9
  import { AuthProcessError } from "../auth/auth-runner.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
@@ -226,6 +226,16 @@ export function authPendingNotice(error) {
226
226
  return "Authentication expired. Auto-reauthentication was attempted but failed. " +
227
227
  `Please run \`${AUTH_COMMAND}\` in your terminal, then try again.`;
228
228
  }
229
+ /**
230
+ * A rejected pasted credential (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE) never
231
+ * recovers by waiting, unlike a pending sign-in. Tools call this before
232
+ * reporting "sign-in is still in progress", so the whole call fails instead
233
+ * and sanitizeError tells the user how to replace the credential.
234
+ */
235
+ export function throwIfCredentialRejected(error) {
236
+ if (error instanceof CredentialRejectedError)
237
+ throw error;
238
+ }
229
239
  /**
230
240
  * Sanitize errors for user-friendly messages
231
241
  *
@@ -263,6 +273,9 @@ function describeError(error) {
263
273
  if (error.status === 404) {
264
274
  return errorResponse("Resource not found. The course or item may not exist, or you may not have access.");
265
275
  }
276
+ if (error instanceof CredentialRejectedError) {
277
+ return errorResponse(error.guidance);
278
+ }
266
279
  if (error.status === 401) {
267
280
  // A stale install is a common cause of sign-in failing, so this is the
268
281
  // one error worth attaching the update notice to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.18",
3
+ "version": "5.1.20",
4
4
  "mcpName": "io.github.RohanMuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",