brightspace-mcp-server 3.8.5 → 3.8.10
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
450
|
+
const settled = results
|
|
399
451
|
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
|
400
452
|
.map((r) => r.value);
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
3
|
+
"version": "3.8.10",
|
|
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",
|