brightspace-mcp-server 3.12.0 → 3.12.2

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.
@@ -48,6 +48,7 @@ export async function fetchAllItems(apiClient, firstPath, options) {
48
48
  }
49
49
  /**
50
50
  * Every object across every page of a { Objects, Next } endpoint, in order.
51
+ * A bare array response is taken as the complete list.
51
52
  *
52
53
  * Next arrives either as a full next-page URL or as a bare bookmark, so the
53
54
  * absolute form is reduced to its path and query and anything else is treated
@@ -65,6 +66,11 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
65
66
  }
66
67
  const response = await apiClient.get(path, options);
67
68
  pages += 1;
69
+ // Some tenants answer a paged route with a bare array: that is the whole list.
70
+ if (Array.isArray(response)) {
71
+ objects.push(...response);
72
+ break;
73
+ }
68
74
  objects.push(...(response?.Objects ?? []));
69
75
  const next = response?.Next;
70
76
  if (!next)
@@ -53,6 +53,14 @@ const CALL_BUDGET_MS = 55000;
53
53
  * from another one) means nobody is approving.
54
54
  */
55
55
  const ABANDON_MS = MFA_POLL_MS;
56
+ /**
57
+ * Calls arriving within this long of a batch's first call count as that batch
58
+ * (issue #212). A client running tool calls in parallel sends them within
59
+ * milliseconds; a model retrying after reading an answer takes a whole round
60
+ * trip, and each call is held up to CALL_BUDGET_MS anyway, so a retry always
61
+ * lands in a fresh batch and gets the number again.
62
+ */
63
+ const WAVE_MS = 5000;
56
64
  /**
57
65
  * Milliseconds until a background sign-in counts as abandoned; zero or less
58
66
  * means it already is. A caller still waiting keeps it alive outright;
@@ -84,6 +92,7 @@ function mfaPendingFailure(numberMatch) {
84
92
  export class AuthProcessError extends AuthError {
85
93
  kind;
86
94
  numberMatch;
95
+ duplicate;
87
96
  constructor(kind, message,
88
97
  /**
89
98
  * Entra number-match digits, when the failure is "mfaPending". Crossed
@@ -91,10 +100,17 @@ export class AuthProcessError extends AuthError {
91
100
  * MFA_NUMBER_MARKER below) — already bounded to 1-3 digits at the point
92
101
  * it was scraped from the page, so it is safe to surface verbatim.
93
102
  */
94
- numberMatch) {
103
+ numberMatch,
104
+ /**
105
+ * Another call in the same parallel batch already carried this exact
106
+ * challenge to the user (issue #212), so this one should answer briefly
107
+ * instead of repeating the number and the instructions.
108
+ */
109
+ duplicate = false) {
95
110
  super(message);
96
111
  this.kind = kind;
97
112
  this.numberMatch = numberMatch;
113
+ this.duplicate = duplicate;
98
114
  this.name = "AuthProcessError";
99
115
  }
100
116
  }
@@ -157,20 +173,21 @@ function forwardLines(stream, emit) {
157
173
  buffered = "";
158
174
  });
159
175
  }
160
- /**
161
- * Launches the brightspace-auth CLI as a child process to
162
- * re-authenticate when the current session has expired.
163
- *
164
- * The child inherits the parent's resolved environment and working directory,
165
- * so both processes read the same account configuration and .env file. It
166
- * runs headless unless D2L_HEADLESS is set explicitly.
167
- *
168
- * run() settles as soon as the child reports an MFA challenge (with or
169
- * without a number to display) rather than waiting for the child to exit —
170
- * a tool call must not block for the whole approval window. The child keeps
171
- * running in the background; a later run() call joins that background child
172
- * instead of spawning a second one.
173
- */
176
+ function dedupeChallenge(error, wave) {
177
+ if (!(error instanceof AuthProcessError) || error.kind !== "mfaPending" || error.duplicate)
178
+ return error;
179
+ const key = error.numberMatch ?? "";
180
+ if (!wave.delivered.has(key)) {
181
+ wave.delivered.add(key);
182
+ // Once a challenge has gone back to the user, the batch is over: every
183
+ // call already in it is a sibling, but a call arriving from now on is a
184
+ // RETRY -- quite possibly because the client hid this very answer -- and
185
+ // must get the digits in full. This is what keeps #201's guarantee.
186
+ wave.closed = true;
187
+ return error;
188
+ }
189
+ return new AuthProcessError(error.kind, error.message, error.numberMatch, true);
190
+ }
174
191
  export class AuthRunner {
175
192
  /**
176
193
  * The login this process already started, if one is still running.
@@ -203,6 +220,12 @@ export class AuthRunner {
203
220
  waiting = 0;
204
221
  /** When the last caller left run(). */
205
222
  lastAttendedAt = 0;
223
+ /**
224
+ * The current parallel batch and the challenges already handed to one of
225
+ * its calls (issue #212). The sign-in itself is shared through inFlight and
226
+ * childDone; this only stops every call in the batch repeating the number.
227
+ */
228
+ wave = null;
206
229
  scriptPath;
207
230
  timeoutMs;
208
231
  onProgress;
@@ -232,15 +255,27 @@ export class AuthRunner {
232
255
  * the next call to join.
233
256
  */
234
257
  async run(onChallenge) {
258
+ // Synchronous, before any await: calls in one tick cannot split a batch.
259
+ const wave = this.joinWave();
235
260
  this.waiting += 1;
236
261
  try {
237
262
  return await this.attend(onChallenge);
238
263
  }
264
+ catch (error) {
265
+ throw dedupeChallenge(error, wave);
266
+ }
239
267
  finally {
240
268
  this.waiting -= 1;
241
269
  this.lastAttendedAt = Date.now();
242
270
  }
243
271
  }
272
+ joinWave() {
273
+ const now = Date.now();
274
+ if (!this.wave || this.wave.closed || now - this.wave.openedAt > WAVE_MS) {
275
+ this.wave = { openedAt: now, delivered: new Set(), closed: false };
276
+ }
277
+ return this.wave;
278
+ }
244
279
  async attend(onChallenge) {
245
280
  const startedAt = Date.now();
246
281
  try {
@@ -427,6 +462,7 @@ export class AuthRunner {
427
462
  this.childDone = null;
428
463
  this.pendingChallenge = null;
429
464
  this.challengeSignal = null;
465
+ this.wave = null;
430
466
  }
431
467
  });
432
468
  trackedCompletion.catch(() => { });
@@ -4,7 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
- import { fetchAllItems } from "../api/paginate.js";
7
+ import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetAssignmentsSchema } from "./schemas.js";
9
9
  import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
10
10
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
@@ -110,7 +110,10 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
110
110
  // cannot be made until they have answered.
111
111
  const [dropboxResult, quizResult, gradebookResult, contentResult] = await Promise.allSettled([
112
112
  apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
113
- apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
113
+ // Quizzes are paged; a course with many of them spills past page one.
114
+ fetchAllObjects(apiClient, apiClient.le(courseId, "/quizzes/"), {
115
+ ttl: DEFAULT_CACHE_TTLS.assignments,
116
+ }),
114
117
  apiClient.get(apiClient.le(courseId, "/grades/"), {
115
118
  ttl: DEFAULT_CACHE_TTLS.assignments,
116
119
  }),
@@ -263,11 +266,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
263
266
  // forbidden or unavailable, so a failed list starts from an empty set.
264
267
  let quizzes = [];
265
268
  if (quizResult.status === "fulfilled") {
266
- const quizResponse = quizResult.value;
267
- // D2L quizzes API returns paged result { Objects: [...] } or a plain array
268
- quizzes = Array.isArray(quizResponse)
269
- ? quizResponse
270
- : quizResponse?.Objects ?? [];
269
+ quizzes = quizResult.value;
271
270
  }
272
271
  else {
273
272
  log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
@@ -4,7 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
- import { fetchAllItems } from "../api/paginate.js";
7
+ import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetMyGradesSchema, } from "./schemas.js";
9
9
  import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
@@ -27,8 +27,10 @@ const QUIZ_FEEDBACK_NOTE = "The gradebook has no comment for this item, but it i
27
27
  */
28
28
  async function fetchQuizzes(apiClient, courseId) {
29
29
  try {
30
- const raw = await apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
31
- return Array.isArray(raw) ? raw : raw?.Objects ?? [];
30
+ // Quizzes are paged; a course with many of them spills past page one.
31
+ return await fetchAllObjects(apiClient, apiClient.le(courseId, "/quizzes/"), {
32
+ ttl: DEFAULT_CACHE_TTLS.assignments,
33
+ });
32
34
  }
33
35
  catch (error) {
34
36
  log("DEBUG", `get_my_grades: quiz list unavailable for course ${courseId}`, error);
@@ -4,6 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { fetchAllObjects } from "../api/paginate.js";
7
8
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
8
9
  import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
10
  import { log } from "../utils/logger.js";
@@ -78,7 +79,10 @@ function calendarItems(events, items) {
78
79
  async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
79
80
  const [dropboxResult, quizResult, discussionResult, calendarResult] = await Promise.allSettled([
80
81
  apiClient.get(apiClient.le(course.id, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
81
- apiClient.get(apiClient.le(course.id, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
82
+ // Quizzes are paged; a course with many of them spills past page one.
83
+ fetchAllObjects(apiClient, apiClient.le(course.id, "/quizzes/"), {
84
+ ttl: DEFAULT_CACHE_TTLS.assignments,
85
+ }),
82
86
  fetchDiscussionDueTopics(apiClient, course.id),
83
87
  fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to),
84
88
  ]);
@@ -117,7 +121,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
117
121
  log("DEBUG", `get_upcoming_due_dates: failed to fetch dropbox folders for course ${course.id}`, dropboxResult.reason);
118
122
  }
119
123
  if (quizResult.status === "fulfilled") {
120
- for (const quiz of unwrapList(quizResult.value)) {
124
+ for (const quiz of quizResult.value) {
121
125
  if (quiz.IsActive === false)
122
126
  continue;
123
127
  // Many instructors set only an End Date, which is the effective deadline.
@@ -122,7 +122,14 @@ const AUTH_FAILURE_GUIDANCE = {
122
122
  * auth-runner.ts MFA_NUMBER_MARKER) — a bounded, pre-validated value, not
123
123
  * arbitrary child output, so it is safe to interpolate here.
124
124
  */
125
+ const MFA_ALREADY_REPORTED = "Brightspace sign-in is already in progress; another call in this batch has the number to " +
126
+ "approve. Retry this call once the sign-in completes.";
125
127
  function authFailureMessage(error) {
128
+ // Another call in this parallel batch already carries the number and the
129
+ // instructions (issue #212). Repeating them in every response made a batch
130
+ // unreadable; one short line is enough for this call.
131
+ if (error.kind === "mfaPending" && error.duplicate)
132
+ return MFA_ALREADY_REPORTED;
126
133
  if (error.kind === "mfaPending" && error.numberMatch) {
127
134
  return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
128
135
  "Tell the user that number, then call this tool again right away without waiting for them " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
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",