brightspace-mcp-server 3.8.5 → 3.8.9

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.
@@ -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 { GetAssignmentsSchema } from "./schemas.js";
9
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } 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";
@@ -66,6 +66,8 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
66
66
  });
67
67
  }
68
68
  catch (error) {
69
+ if (isAuthUnavailable(error))
70
+ throw error;
69
71
  log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
70
72
  }
71
73
  let detail = topic;
@@ -73,6 +75,8 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
73
75
  detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
74
76
  }
75
77
  catch (error) {
78
+ if (isAuthUnavailable(error))
79
+ throw error;
76
80
  log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
77
81
  }
78
82
  return {
@@ -109,6 +113,12 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
109
113
  ttl: DEFAULT_CACHE_TTLS.courseContent,
110
114
  }),
111
115
  ]);
116
+ // Each route may fail on its own without costing the others, but a failed
117
+ // sign-in means none of them answered: report that, not an empty course.
118
+ for (const result of [dropboxResult, quizResult, gradebookResult, contentResult]) {
119
+ if (result.status === "rejected" && isAuthUnavailable(result.reason))
120
+ throw result.reason;
121
+ }
112
122
  // Process Dropbox folders
113
123
  if (dropboxResult.status === "fulfilled") {
114
124
  // D2L dropbox endpoint may return paged { Objects: [...] } or flat array
@@ -125,6 +135,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
125
135
  submissions = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
126
136
  }
127
137
  catch (error) {
138
+ if (isAuthUnavailable(error))
139
+ throw error;
128
140
  // 404 means no submissions yet - that's fine
129
141
  if (error?.status !== 404) {
130
142
  log("DEBUG", `Failed to fetch submissions for folder ${folder.Id}`, error);
@@ -136,6 +148,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
136
148
  feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
137
149
  }
138
150
  catch (error) {
151
+ if (isAuthUnavailable(error))
152
+ throw error;
139
153
  // 404/403 means no feedback available (or no access) - that's fine
140
154
  if (error?.status !== 404 && error?.status !== 403) {
141
155
  log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
@@ -229,6 +243,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
229
243
  attempts = Array.isArray(attemptsRaw) ? attemptsRaw : attemptsRaw.Objects ?? [];
230
244
  }
231
245
  catch (error) {
246
+ if (isAuthUnavailable(error))
247
+ throw error;
232
248
  if (error?.status === 404) {
233
249
  // 404 means no attempts yet, which is a measurement of zero
234
250
  attempts = [];
@@ -353,9 +369,31 @@ export function registerGetAssignments(server, apiClient, config) {
353
369
  const { courseId } = GetAssignmentsSchema.parse(args);
354
370
  // Single course case
355
371
  if (courseId) {
356
- const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
357
- log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
358
- return toolResponse({ courseId, assignments });
372
+ try {
373
+ const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
374
+ log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
375
+ return toolResponse({ courseId, assignments });
376
+ }
377
+ catch (error) {
378
+ // A pending sign-in is not an empty course: the routes never
379
+ // answered, so the result says so instead of reporting zero
380
+ // assignments as if that were a real measurement. The envelope
381
+ // stays a success — existing callers that only read `assignments`
382
+ // keep working — with authPending/notice added for callers that
383
+ // want to tell "no assignments" apart from "couldn't check".
384
+ if (isAuthUnavailable(error)) {
385
+ log("DEBUG", `get_assignments: sign-in pending for course ${courseId}`, error);
386
+ return toolResponse({
387
+ courseId,
388
+ assignments: [],
389
+ authPending: true,
390
+ notice: "Sign-in to Brightspace is still in progress, so assignments for this course " +
391
+ `could not be fetched yet. ${authPendingNotice(error)} Call get_assignments again ` +
392
+ "once sign-in finishes.",
393
+ });
394
+ }
395
+ throw error;
396
+ }
359
397
  }
360
398
  // All courses case
361
399
  // First, fetch enrolled courses. isActive=true is the configured
@@ -391,15 +429,57 @@ export function registerGetAssignments(server, apiClient, config) {
391
429
  log("DEBUG", `get_assignments: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
392
430
  return null;
393
431
  }
432
+ // A pending sign-in only means this course's routes never
433
+ // answered — it says nothing about the other courses, whose
434
+ // requests may already have gone out independently. Mark this one
435
+ // rather than failing the whole call and losing every course that
436
+ // *did* answer.
437
+ if (isAuthUnavailable(error)) {
438
+ log("DEBUG", `get_assignments: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
439
+ return {
440
+ courseId: item.OrgUnit.Id,
441
+ courseName: item.OrgUnit.Name,
442
+ authPending: true,
443
+ authError: error,
444
+ };
445
+ }
394
446
  throw error; // Re-throw other errors
395
447
  }
396
448
  });
397
449
  const results = await Promise.allSettled(assignmentPromises);
398
- const courses = results
450
+ const settled = results
399
451
  .filter((r) => r.status === "fulfilled" && r.value !== null)
400
452
  .map((r) => r.value);
401
- log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses (out of ${enrollmentItems.length} enrolled)`);
402
- return toolResponse({ courses });
453
+ const pending = settled.filter((c) => c.authPending);
454
+ const courses = settled
455
+ .filter((c) => !c.authPending)
456
+ .map(({ courseId, courseName, assignments }) => ({ courseId, courseName, assignments }));
457
+ log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses ` +
458
+ `(out of ${enrollmentItems.length} enrolled${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
459
+ // Pending courses keep `assignments: []` so callers that read
460
+ // `courses[i].assignments` still get an array; authPending marks it
461
+ // as unchecked rather than empty.
462
+ const response = {
463
+ courses: [
464
+ ...courses,
465
+ ...pending.map(({ courseId, courseName }) => ({
466
+ courseId,
467
+ courseName,
468
+ assignments: [],
469
+ authPending: true,
470
+ })),
471
+ ],
472
+ };
473
+ if (pending.length > 0) {
474
+ response.authPending = true;
475
+ response.unavailableCourseIds = pending.map((c) => c.courseId);
476
+ response.notice =
477
+ "Sign-in to Brightspace is still in progress, so assignments for " +
478
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
479
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_assignments again once ` +
480
+ "sign-in finishes.";
481
+ }
482
+ return toolResponse(response);
403
483
  }
404
484
  catch (error) {
405
485
  // Temporary: log full error details to stderr for debugging
@@ -108,6 +108,40 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
108
108
  pathTraversal: "The name Brightspace gave this file pointed outside the download " +
109
109
  "directory and was refused. Pass customFilename to choose one yourself.",
110
110
  };
111
+ /**
112
+ * True when an error means the session itself is unusable, not that one
113
+ * resource refused: a sign-in that failed or is still waiting on MFA, a 401
114
+ * left over after re-authentication, or a token that could not be renewed.
115
+ * Tools that tolerate per-route failures must not tolerate these, or a pending
116
+ * sign-in reads as a successful empty result.
117
+ */
118
+ export function isAuthUnavailable(error) {
119
+ return error instanceof AuthProcessError ||
120
+ error instanceof TokenRefreshError ||
121
+ (error instanceof ApiError && error.status === 401);
122
+ }
123
+ /**
124
+ * Guidance for a tool that returned a *successful* envelope anyway, because
125
+ * the sign-in blocking one or more of its routes is still being worked on in
126
+ * the background (see isAuthUnavailable). Reuses the same per-kind wording
127
+ * sanitizeError gives an AuthProcessError — including the dynamic MFA number,
128
+ * when one is known — so a caller sees identical instructions whether the
129
+ * tool reported isError or not; only the framing differs. Only call this once
130
+ * isAuthUnavailable(error) is true; it has no fallback for other errors.
131
+ */
132
+ export function authPendingNotice(error) {
133
+ if (error instanceof AuthProcessError) {
134
+ return authFailureMessage(error);
135
+ }
136
+ if (error instanceof TokenRefreshError) {
137
+ return "Brightspace could not renew your session right now. Your saved login was kept. " +
138
+ "Try again in a few minutes.";
139
+ }
140
+ // The remaining isAuthUnavailable case: a 401 ApiError left over after
141
+ // automatic re-authentication was attempted and failed.
142
+ return "Authentication expired. Auto-reauthentication was attempted but failed. " +
143
+ `Please run \`${AUTH_COMMAND}\` in your terminal, then try again.`;
144
+ }
111
145
  /**
112
146
  * Sanitize errors for user-friendly messages
113
147
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.5",
3
+ "version": "3.8.9",
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",