brightspace-mcp-server 3.8.4 → 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.
package/README.md CHANGED
@@ -103,6 +103,8 @@ Claude Desktop uses a separate configuration, which the setup wizard can update
103
103
 
104
104
  </details>
105
105
 
106
+ **Running from a source checkout or fork?** When a newer release is published, the server tells a source checkout to `git pull` and `npm run build` rather than to install the npm package. For a fork you maintain, set `D2L_NO_UPDATE_CHECK=1` in the server's environment to turn off upstream update notices.
107
+
106
108
  ## Session Expired?
107
109
 
108
110
  There's no login step — asking a question signs you in.
@@ -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
  *
@@ -18,6 +18,10 @@
18
18
  * caller happens to read it first. A one-shot notice is invisible in practice:
19
19
  * it gets swallowed by a single background tool call and never seen again.
20
20
  *
21
+ * A source checkout gets different advice: installing the npm package would
22
+ * update some other copy, and for a fork it would mean leaving its changes
23
+ * behind. Such a checkout is told to pull and rebuild, or to opt out.
24
+ *
21
25
  * Set D2L_NO_UPDATE_CHECK to any value to switch the check off entirely.
22
26
  */
23
27
  import { readFileSync } from "node:fs";
@@ -54,8 +58,17 @@ export function ownNpxCacheDir(root = projectRoot) {
54
58
  const match = new RegExp(`^(.*/_npx/[^/]+)/node_modules/${PACKAGE_NAME}`).exec(normalized);
55
59
  return match ? resolve(match[1]) : null;
56
60
  }
57
- function isNpxCache() {
58
- return ownNpxCacheDir() !== null;
61
+ /**
62
+ * Classify the package directory this process runs from. npm only ever puts
63
+ * the package under a `node_modules` directory, so anything else is a checkout
64
+ * someone built by hand, possibly a fork.
65
+ */
66
+ export function installKindOf(root = projectRoot) {
67
+ if (ownNpxCacheDir(root) !== null)
68
+ return "npx-cache";
69
+ return root.split(sep).join("/").split("/").includes("node_modules")
70
+ ? "npm-install"
71
+ : "source-checkout";
59
72
  }
60
73
  /**
61
74
  * Remove npx cache entries holding a copy of this package, so the next
@@ -155,7 +168,7 @@ export async function fetchLatestVersion(fetchImpl = fetch) {
155
168
  * swallowed, because a version check must not affect the server.
156
169
  */
157
170
  export async function initUpdateChecker(deps = {}) {
158
- const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(), runningFromNpxCache = isNpxCache(), clearCaches = () => clearAllNpxCaches(), } = deps;
171
+ const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(), installKind = installKindOf(), clearCaches = () => clearAllNpxCaches(), } = deps;
159
172
  if (env.D2L_NO_UPDATE_CHECK)
160
173
  return;
161
174
  try {
@@ -168,7 +181,7 @@ export async function initUpdateChecker(deps = {}) {
168
181
  const to = safeVersionLabel(latest);
169
182
  const headline = `Update available: v${from} to v${to}.`;
170
183
  let text;
171
- if (runningFromNpxCache) {
184
+ if (installKind === "npx-cache") {
172
185
  const count = await clearCaches();
173
186
  const cleanup = count > 0
174
187
  ? `Cleared ${count} stale npx cache director${count === 1 ? "y" : "ies"} for ${PACKAGE_NAME} ` +
@@ -176,6 +189,11 @@ export async function initUpdateChecker(deps = {}) {
176
189
  : "";
177
190
  text = `${headline} ${cleanup}Restart your MCP client to pick up v${to}.`;
178
191
  }
192
+ else if (installKind === "source-checkout") {
193
+ text = `${headline} This server runs from a source checkout, so update that checkout ` +
194
+ `(git pull, then npm run build) and restart your MCP client. ` +
195
+ `If it is a fork you maintain, set D2L_NO_UPDATE_CHECK=1 to turn off upstream update notices.`;
196
+ }
179
197
  else {
180
198
  text = `${headline} Run: ${GLOBAL_INSTALL_COMMAND}` +
181
199
  `, then ${CLEAR_NPX_CACHE_COMMAND} and restart your MCP client.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.4",
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",