brightspace-mcp-server 3.13.16 → 3.13.18
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 +1 -1
- package/build/index.js +4 -1
- package/build/tools/get-assignments.js +441 -72
- package/build/tools/index.js +1 -0
- package/build/tools/omitted-arguments.js +34 -0
- package/build/utils/deep-links.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
68
68
|
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
|
|
69
69
|
| Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
|
|
70
70
|
| Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
|
|
71
|
-
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
|
|
71
|
+
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" (quizzes are collected from the quiz list, the course content, and the gradebook, so a closed quiz or one the list leaves out still shows up; each course says whether its quiz sources all answered, and if Brightspace refused one it's marked incomplete rather than passed off as everything; "complete" means every source answered, not that every quiz is guaranteed to be listed. A gradebook column linked to a quiz the list omits now shows up as a quiz with its dates and attempts instead of a bare grade-only row when the quiz can be read. Gradebook-only rows now also appear on tenants that send only the documented `GradeType` on grade columns; hidden grade columns, hidden assignments and inactive quizzes never produce one. Content quizzes beyond the first 30 per course are listed from the course content alone and the course is marked incomplete. Quiz rows may also say whether they are open, closed, or not yet open, and mark a quiz whose details Brightspace would not return; gradebook lookups for unlisted quizzes are capped at 20 per course, or 5 when the course's quiz tool can't be identified) |
|
|
72
72
|
| Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
|
|
73
73
|
| Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
|
|
74
74
|
| Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
|
package/build/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { configureDevActivity, trackToolActivity } from "./utils/dev-activity.js
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { dirname, resolve } from "node:path";
|
|
21
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
21
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
22
22
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
23
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
24
|
const __dirname = dirname(__filename);
|
|
@@ -121,6 +121,9 @@ else {
|
|
|
121
121
|
activeOnly: config.courseFilter.activeOnly,
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
|
+
// A client may leave `arguments` off a call to a tool that takes none;
|
|
125
|
+
// SDK 1.26-1.31 reject that. Must run before the first registration.
|
|
126
|
+
acceptOmittedArguments(server);
|
|
124
127
|
// Register MCP tools. Each one, when its client sends a progress token,
|
|
125
128
|
// shows an MFA challenge mid-call and waits for the approval instead of
|
|
126
129
|
// asking to be called again (see relayMfaChallenges).
|
|
@@ -10,7 +10,7 @@ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } fro
|
|
|
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";
|
|
13
|
-
import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
13
|
+
import { contentTopicUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
14
14
|
import { assignmentLinkResolver } from "./assignment-links.js";
|
|
15
15
|
import { dueIn } from "../utils/due-in.js";
|
|
16
16
|
import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
|
|
@@ -24,36 +24,127 @@ function richTextHtml(field) {
|
|
|
24
24
|
return nested ?? field.Html ?? null;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* The grade
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* The grade types a student is actually scored on. D2L documents exactly four
|
|
28
|
+
* GradeObject shapes, told apart by a string GradeType; anything else a
|
|
29
|
+
* gradebook lists (category, calculated, formula, final grade) is the
|
|
30
|
+
* gradebook's own arithmetic, not work anybody owes. Those are not documented
|
|
31
|
+
* as GradeType values, so a column is excluded when its GradeType is some
|
|
32
|
+
* other string, and a calculated column also carries no AssociatedTool.
|
|
30
33
|
*/
|
|
34
|
+
const STUDENT_SCORED_TYPES = new Set(["numeric", "passfail", "selectbox", "text"]);
|
|
35
|
+
/** The numeric GRADEOBJ_T values for the same four types: 1 numeric, 2 passfail, 3 selectbox, 4 text. */
|
|
31
36
|
const STUDENT_SCORED = new Set([1, 2, 3, 4]);
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
function isStudentScored(column) {
|
|
38
|
+
if (!column || typeof column !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
if (typeof column.GradeType === "string" && STUDENT_SCORED_TYPES.has(column.GradeType.toLowerCase()))
|
|
41
|
+
return true;
|
|
42
|
+
return STUDENT_SCORED.has(column.GradeObjectTypeId);
|
|
43
|
+
}
|
|
44
|
+
/** The columns a student is scored on, from whatever the grades route returned. */
|
|
45
|
+
function studentScoredColumns(gradebook) {
|
|
46
|
+
if (!Array.isArray(gradebook))
|
|
47
|
+
return [];
|
|
48
|
+
return gradebook.filter((c) => isStudentScored(c) && typeof c.Id === "number");
|
|
49
|
+
}
|
|
50
|
+
/** The quiz id a content topic points at, or null when it carries none. */
|
|
51
|
+
function contentQuizId(topic) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
topic.ToolItemId,
|
|
54
|
+
Number(topic.ActivityId),
|
|
55
|
+
// quiz_summary.d2l?qi=N and quiz_start_frame_auto.d2l?qi=N: the same qi
|
|
56
|
+
// parameter quizUrl() builds, present when ToolItemId is not.
|
|
57
|
+
Number(/(?:[?&]|&)qi=(\d+)(?:[&#]|$)/i.exec(topic.Url ?? "")?.[1]),
|
|
58
|
+
];
|
|
59
|
+
for (const id of candidates) {
|
|
60
|
+
if (typeof id === "number" && Number.isSafeInteger(id) && id > 0)
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** Titles compare by words, not by case or spacing, so a topic can be matched to a listed quiz. */
|
|
66
|
+
function normalizedTitle(title) {
|
|
67
|
+
return typeof title === "string" ? title.trim().replace(/\s+/g, " ").toLowerCase() : "";
|
|
68
|
+
}
|
|
69
|
+
/** Every quiz topic in a table of contents, depth first, hidden or broken ones included. */
|
|
70
|
+
function quizTopicsOf(raw) {
|
|
34
71
|
if (!raw || typeof raw !== "object")
|
|
35
72
|
return [];
|
|
36
|
-
const found =
|
|
37
|
-
const visit = (modules) => {
|
|
38
|
-
if (!Array.isArray(modules))
|
|
73
|
+
const found = [];
|
|
74
|
+
const visit = (modules, depth) => {
|
|
75
|
+
if (!Array.isArray(modules) || depth > 50)
|
|
39
76
|
return;
|
|
40
77
|
for (const module of modules) {
|
|
41
78
|
for (const topic of module.Topics ?? []) {
|
|
42
79
|
const isQuiz = topic.ActivityType === 4
|
|
43
80
|
|| topic.TypeIdentifier?.toLowerCase() === "quiz"
|
|
44
|
-
|| /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!Number.isSafeInteger(activityId) || activityId <= 0 || listedIds.has(activityId))
|
|
49
|
-
continue;
|
|
50
|
-
if (!found.has(activityId))
|
|
51
|
-
found.set(activityId, topic);
|
|
81
|
+
|| /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "")
|
|
82
|
+
|| /\/lms\/quizzing\/user\/quiz_/i.test(topic.Url ?? "");
|
|
83
|
+
if (isQuiz)
|
|
84
|
+
found.push(topic);
|
|
52
85
|
}
|
|
53
|
-
visit(module.Modules);
|
|
86
|
+
visit(module.Modules, depth + 1);
|
|
54
87
|
}
|
|
55
88
|
};
|
|
56
|
-
visit(raw.Modules);
|
|
89
|
+
visit(raw.Modules, 0);
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The quiz tool's id as content quiz topics report it (ToolId, documented on
|
|
94
|
+
* every ToC topic). Tells the gradebook probe which columns belong to quizzes.
|
|
95
|
+
*/
|
|
96
|
+
function contentQuizToolIds(raw) {
|
|
97
|
+
return new Set(quizTopicsOf(raw)
|
|
98
|
+
.map((topic) => topic.ToolId)
|
|
99
|
+
.filter((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The quiz id behind a topic that names none, when the grade column the topic
|
|
103
|
+
* writes to (GradeItemId, documented on ToC topics) is linked to a tool item.
|
|
104
|
+
* A column of a different tool than the topic's is not the topic's quiz.
|
|
105
|
+
*/
|
|
106
|
+
function quizIdFromGradeColumn(topic, columnsById) {
|
|
107
|
+
if (typeof topic.GradeItemId !== "number")
|
|
108
|
+
return null;
|
|
109
|
+
const tool = columnsById.get(topic.GradeItemId)?.AssociatedTool;
|
|
110
|
+
const itemId = tool?.ToolItemId;
|
|
111
|
+
if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
if (typeof topic.ToolId === "number" && typeof tool?.ToolId === "number" && topic.ToolId !== tool.ToolId) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return itemId;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Quiz topics visible in course content but absent from the quiz listing.
|
|
120
|
+
* Topics are returned once each. Those that name a quiz (directly, or through
|
|
121
|
+
* their grade column) are keyed by id; one that names none is kept only if no
|
|
122
|
+
* listed quiz carries its title, and one linked from several modules is kept
|
|
123
|
+
* once, recognized by its title and link.
|
|
124
|
+
*/
|
|
125
|
+
function missingContentQuizTopics(raw, listedIds, listedTitles, columnsById) {
|
|
126
|
+
const found = new Map();
|
|
127
|
+
for (const topic of quizTopicsOf(raw)) {
|
|
128
|
+
// A hidden or broken topic is not something the student can open. An
|
|
129
|
+
// exempt one (IsExempt: the user is exempt from the topic, per the
|
|
130
|
+
// content docs) is still listed, and flagged as exempt on its row.
|
|
131
|
+
if (topic.IsHidden || topic.IsBroken)
|
|
132
|
+
continue;
|
|
133
|
+
const quizId = contentQuizId(topic) ?? quizIdFromGradeColumn(topic, columnsById);
|
|
134
|
+
if (quizId !== null) {
|
|
135
|
+
if (listedIds.has(quizId))
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
else if (listedTitles.has(normalizedTitle(topic.Title))) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const url = (topic.Url ?? "").trim();
|
|
142
|
+
const key = quizId !== null
|
|
143
|
+
? `quiz:${quizId}`
|
|
144
|
+
: url !== "" ? `link:${normalizedTitle(topic.Title)}|${url}` : `topic:${topic.TopicId}`;
|
|
145
|
+
if (!found.has(key))
|
|
146
|
+
found.set(key, { topic, quizId });
|
|
147
|
+
}
|
|
57
148
|
return [...found.values()];
|
|
58
149
|
}
|
|
59
150
|
/**
|
|
@@ -62,40 +153,234 @@ function missingContentQuizTopics(raw, listedIds) {
|
|
|
62
153
|
* us the normal full shape. If that route is also unavailable, the content
|
|
63
154
|
* topic still provides enough information to avoid silently hiding the work.
|
|
64
155
|
*/
|
|
65
|
-
async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
156
|
+
async function recoverContentQuizzes(apiClient, courseId, toc, listedIds, listedTitles, columnsById) {
|
|
157
|
+
const topics = missingContentQuizTopics(toc, listedIds, listedTitles, columnsById);
|
|
158
|
+
// Only the first topics are asked for their details; the rest are listed
|
|
159
|
+
// from the table of contents alone, so a huge course costs a bounded number
|
|
160
|
+
// of requests and no quiz is dropped.
|
|
161
|
+
const readable = Math.min(topics.length, MAX_CONTENT_QUIZ_RECOVERIES);
|
|
162
|
+
const found = await Promise.all(topics.map(async ({ topic, quizId }, index) => {
|
|
163
|
+
const exempt = topic.IsExempt === true ? { exempt: true } : {};
|
|
164
|
+
const mayRead = index < MAX_CONTENT_QUIZ_RECOVERIES;
|
|
165
|
+
if (mayRead && quizId !== null) {
|
|
166
|
+
try {
|
|
167
|
+
const quiz = await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
|
|
168
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
169
|
+
});
|
|
170
|
+
return { quiz, source: "courseContent", topicId: topic.TopicId, ...exempt };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (isAuthUnavailable(error))
|
|
174
|
+
throw error;
|
|
175
|
+
log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
|
|
176
|
+
}
|
|
77
177
|
}
|
|
78
178
|
let detail = topic;
|
|
179
|
+
if (mayRead) {
|
|
180
|
+
try {
|
|
181
|
+
detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (isAuthUnavailable(error))
|
|
185
|
+
throw error;
|
|
186
|
+
log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
source: "courseContent",
|
|
191
|
+
topicId: topic.TopicId,
|
|
192
|
+
...exempt,
|
|
193
|
+
quiz: {
|
|
194
|
+
QuizId: quizId,
|
|
195
|
+
Name: detail.Title || topic.Title,
|
|
196
|
+
Description: detail.Description ?? null,
|
|
197
|
+
StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
|
|
198
|
+
EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
|
|
199
|
+
DueDate: detail.DueDate ?? null,
|
|
200
|
+
IsActive: true,
|
|
201
|
+
AttemptsAllowed: null,
|
|
202
|
+
ContentMetadataOnly: true,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}));
|
|
206
|
+
return { found, considered: topics.length, read: readable };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Content quizzes per course whose own record (and, failing that, content
|
|
210
|
+
* topic) is read. Each may cost two requests, so a course costs at most 60.
|
|
211
|
+
* Past the cap a quiz is still listed, from the table of contents alone, and
|
|
212
|
+
* the course's quizCoverage says the cap applied.
|
|
213
|
+
*/
|
|
214
|
+
const MAX_CONTENT_QUIZ_RECOVERIES = 30;
|
|
215
|
+
/**
|
|
216
|
+
* Probes per course. A course whose quiz tool is known (a listed or content
|
|
217
|
+
* quiz sits on it) is asked about that tool's other columns, up to 20. With no
|
|
218
|
+
* known quiz tool every non-dropbox column is a candidate, so far fewer are
|
|
219
|
+
* asked: most of them are discussions, surveys or manual items.
|
|
220
|
+
*/
|
|
221
|
+
const MAX_GRADEBOOK_QUIZ_PROBES = 20;
|
|
222
|
+
const MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES = 5;
|
|
223
|
+
/**
|
|
224
|
+
* A quiz read that answered 404 or 403 will answer the same again soon, so it
|
|
225
|
+
* is not repeated for ten minutes. Kept per client (so per signed-in account)
|
|
226
|
+
* and only for those two answers: a 401 is a sign-in matter and a 5xx or a
|
|
227
|
+
* network failure may pass.
|
|
228
|
+
*/
|
|
229
|
+
const PROBE_REFUSAL_TTL_MS = 10 * 60 * 1000;
|
|
230
|
+
const refusedProbes = new WeakMap();
|
|
231
|
+
function probeRecentlyRefused(apiClient, path, now) {
|
|
232
|
+
const until = refusedProbes.get(apiClient)?.get(path);
|
|
233
|
+
if (until === undefined)
|
|
234
|
+
return false;
|
|
235
|
+
if (until > now)
|
|
236
|
+
return true;
|
|
237
|
+
refusedProbes.get(apiClient).delete(path);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
function rememberRefusedProbe(apiClient, path, now) {
|
|
241
|
+
let paths = refusedProbes.get(apiClient);
|
|
242
|
+
if (!paths) {
|
|
243
|
+
paths = new Map();
|
|
244
|
+
refusedProbes.set(apiClient, paths);
|
|
245
|
+
}
|
|
246
|
+
paths.set(path, now + PROBE_REFUSAL_TTL_MS);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Quizzes behind gradebook columns that nothing else accounted for. A column
|
|
250
|
+
* linked to a tool item (AssociatedTool) the quiz list and content both omit
|
|
251
|
+
* may still be a quiz the student can read by id, so it is asked for once.
|
|
252
|
+
* The answer is accepted only when the quiz names that very column as its
|
|
253
|
+
* GradeItemId: a discussion or survey that happens to share the id answers
|
|
254
|
+
* 404 or names a different column, and stays a bare gradebook row.
|
|
255
|
+
*
|
|
256
|
+
* A column the instructor hid from students is not asked. The folder ids
|
|
257
|
+
* include hidden folders, whose columns are never asked either.
|
|
258
|
+
* Tools already seen on a dropbox folder are not asked; once a quiz tool is
|
|
259
|
+
* known (from a listed or content quiz), only that tool's other columns are.
|
|
260
|
+
* A column named like an id-less content quiz is that quiz, not a new one.
|
|
261
|
+
*/
|
|
262
|
+
async function recoverGradebookQuizzes(apiClient, courseId, columns, folderIds, quizIds, contentQuizTools, idlessQuizTitles) {
|
|
263
|
+
const toolIdsOf = (ids) => new Set(columns
|
|
264
|
+
.filter((c) => ids.has(c.AssociatedTool?.ToolItemId))
|
|
265
|
+
.map((c) => c.AssociatedTool.ToolId));
|
|
266
|
+
const quizTools = new Set([...toolIdsOf(quizIds), ...contentQuizTools]);
|
|
267
|
+
const folderTools = toolIdsOf(folderIds);
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const candidates = columns
|
|
270
|
+
.filter((c) => {
|
|
271
|
+
const itemId = c.AssociatedTool?.ToolItemId;
|
|
272
|
+
if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
|
|
273
|
+
return false;
|
|
274
|
+
if (c.IsHidden === true)
|
|
275
|
+
return false;
|
|
276
|
+
if (quizIds.has(itemId) || folderIds.has(itemId))
|
|
277
|
+
return false;
|
|
278
|
+
if (idlessQuizTitles.has(normalizedTitle(c.Name)))
|
|
279
|
+
return false;
|
|
280
|
+
if (probeRecentlyRefused(apiClient, apiClient.le(courseId, `/quizzes/${itemId}`), now))
|
|
281
|
+
return false;
|
|
282
|
+
if (quizTools.size > 0)
|
|
283
|
+
return quizTools.has(c.AssociatedTool.ToolId);
|
|
284
|
+
return !folderTools.has(c.AssociatedTool.ToolId);
|
|
285
|
+
})
|
|
286
|
+
.slice(0, quizTools.size > 0 ? MAX_GRADEBOOK_QUIZ_PROBES : MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES);
|
|
287
|
+
const found = await Promise.all(candidates.map(async (column) => {
|
|
288
|
+
const itemId = column.AssociatedTool.ToolItemId;
|
|
289
|
+
const path = apiClient.le(courseId, `/quizzes/${itemId}`);
|
|
79
290
|
try {
|
|
80
|
-
|
|
291
|
+
const quiz = await apiClient.get(path, { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
292
|
+
if (quiz?.QuizId === itemId && quiz.GradeItemId === column.Id) {
|
|
293
|
+
return { quiz, source: "gradebook" };
|
|
294
|
+
}
|
|
81
295
|
}
|
|
82
296
|
catch (error) {
|
|
83
297
|
if (isAuthUnavailable(error))
|
|
84
298
|
throw error;
|
|
85
|
-
|
|
299
|
+
if (error?.status === 404 || error?.status === 403)
|
|
300
|
+
rememberRefusedProbe(apiClient, path, now);
|
|
301
|
+
log("DEBUG", `Gradebook column ${column.Id} is not a readable quiz (${itemId})`, error);
|
|
86
302
|
}
|
|
87
|
-
return
|
|
88
|
-
QuizId: quizId,
|
|
89
|
-
Name: detail.Title || topic.Title,
|
|
90
|
-
Description: detail.Description ?? null,
|
|
91
|
-
StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
|
|
92
|
-
EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
|
|
93
|
-
DueDate: detail.DueDate ?? null,
|
|
94
|
-
IsActive: true,
|
|
95
|
-
AttemptsAllowed: null,
|
|
96
|
-
ContentMetadataOnly: true,
|
|
97
|
-
};
|
|
303
|
+
return null;
|
|
98
304
|
}));
|
|
305
|
+
return found.filter((item) => item !== null);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The content table of contents with date-restricted items included. D2L's
|
|
309
|
+
* default view omits modules and topics outside their date window, which is
|
|
310
|
+
* exactly where a closed (or not yet opened) quiz sits; ignoreDateRestrictions
|
|
311
|
+
* is documented to be open to students, and supported from LE 1.67, which the
|
|
312
|
+
* client's latest version covers. D2L documents that unsupported parameters
|
|
313
|
+
* are ignored, so the plain view is only a fallback for a request the tenant
|
|
314
|
+
* rejects outright (400, 403, 404), reported as such so coverage is not
|
|
315
|
+
* overstated. Anything else (429, a 5xx, a dropped connection) says nothing
|
|
316
|
+
* about the parameter, so it is not retried here: the content counts as unread.
|
|
317
|
+
*/
|
|
318
|
+
async function fetchContentToc(apiClient, courseId) {
|
|
319
|
+
try {
|
|
320
|
+
const toc = await apiClient.get(apiClient.le(courseId, "/content/toc?ignoreDateRestrictions=true&ignoreModuleDateRestrictions=true"), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
321
|
+
return { toc, datesIncluded: true };
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
if (isAuthUnavailable(error))
|
|
325
|
+
throw error;
|
|
326
|
+
const status = error?.status;
|
|
327
|
+
if (status !== 400 && status !== 403 && status !== 404)
|
|
328
|
+
throw error;
|
|
329
|
+
log("DEBUG", `Content table of contents with date restrictions ignored was refused (${status}) for course ${courseId}: using the default view`, error);
|
|
330
|
+
}
|
|
331
|
+
const toc = await apiClient.get(apiClient.le(courseId, "/content/toc"), {
|
|
332
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
333
|
+
});
|
|
334
|
+
return { toc, datesIncluded: false };
|
|
335
|
+
}
|
|
336
|
+
const DETAILS_UNAVAILABLE_NOTE = "Brightspace showed this quiz in the course content but would not return its details to you; " +
|
|
337
|
+
"it may be closed, not yet open, or limited to certain students. Open the link to check.";
|
|
338
|
+
/**
|
|
339
|
+
* Whether a quiz is open, from its own start and end dates alone: before the
|
|
340
|
+
* start it is not yet open, after the end it is closed. With neither date
|
|
341
|
+
* (or none that parses) nothing is said, and a due date never closes a quiz.
|
|
342
|
+
*/
|
|
343
|
+
function availabilityField(startDate, endDate, now = Date.now()) {
|
|
344
|
+
const start = startDate ? new Date(startDate).getTime() : NaN;
|
|
345
|
+
const end = endDate ? new Date(endDate).getTime() : NaN;
|
|
346
|
+
if (!Number.isFinite(start) && !Number.isFinite(end))
|
|
347
|
+
return {};
|
|
348
|
+
if (Number.isFinite(start) && now < start)
|
|
349
|
+
return { availability: "notYetOpen" };
|
|
350
|
+
if (Number.isFinite(end) && now > end)
|
|
351
|
+
return { availability: "closed" };
|
|
352
|
+
return { availability: "open" };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Coverage of the three routes quizzes are found through. Any one that did
|
|
356
|
+
* not answer may hide quizzes the others cannot see, so the inventory is only
|
|
357
|
+
* complete when all three did.
|
|
358
|
+
*/
|
|
359
|
+
function quizCoverage(answered) {
|
|
360
|
+
const unread = [
|
|
361
|
+
answered.quizList ? null : "the course's quiz list",
|
|
362
|
+
answered.content === "full" ? null
|
|
363
|
+
: answered.content === "dateRestricted"
|
|
364
|
+
? "the course content with date-restricted items included (quizzes whose module or access window has not opened or has closed are hidden from the default view)"
|
|
365
|
+
: "the course content (checked for quizzes the quiz list omits)",
|
|
366
|
+
answered.gradebook ? null : "the gradebook (checked for graded work the quiz list omits)",
|
|
367
|
+
].filter((source) => source !== null);
|
|
368
|
+
const recovery = answered.contentRecovery;
|
|
369
|
+
const truncated = recovery !== undefined && recovery.read < recovery.considered;
|
|
370
|
+
if (unread.length === 0 && !truncated)
|
|
371
|
+
return { quizCoverage: "complete" };
|
|
372
|
+
return {
|
|
373
|
+
quizCoverage: "incomplete",
|
|
374
|
+
quizCoverageNote: (unread.length > 0
|
|
375
|
+
? `Brightspace did not return ${unread.join(", or ")}, so this course may have quizzes not listed here. `
|
|
376
|
+
: "") +
|
|
377
|
+
(truncated
|
|
378
|
+
? `Brightspace read the details of only the first ${recovery.read} of ${recovery.considered} quizzes found in the course content; ` +
|
|
379
|
+
"the rest are listed from the course content alone, without their details or attempts. "
|
|
380
|
+
: "") +
|
|
381
|
+
"Do not treat the listed quizzes as all of them, and do not report an unlisted quiz as missing or not taken - " +
|
|
382
|
+
"tell the user to check Brightspace directly.",
|
|
383
|
+
};
|
|
99
384
|
}
|
|
100
385
|
/**
|
|
101
386
|
* Fetch assignments (dropbox + quizzes) for a single course
|
|
@@ -103,6 +388,10 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
|
103
388
|
* baseUrl is optional: without it the items carry a null url.
|
|
104
389
|
*/
|
|
105
390
|
export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
391
|
+
return (await fetchCourseInventory(apiClient, courseId, baseUrl)).assignments;
|
|
392
|
+
}
|
|
393
|
+
/** A course's assignments and quizzes, with whether its quiz list is complete. */
|
|
394
|
+
async function fetchCourseInventory(apiClient, courseId, baseUrl) {
|
|
106
395
|
const assignments = [];
|
|
107
396
|
const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
|
|
108
397
|
// Fetch dropbox folders, quizzes, and the gradebook in parallel. The
|
|
@@ -118,9 +407,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
118
407
|
apiClient.get(apiClient.le(courseId, "/grades/"), {
|
|
119
408
|
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
120
409
|
}),
|
|
121
|
-
|
|
122
|
-
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
123
|
-
}),
|
|
410
|
+
fetchContentToc(apiClient, courseId),
|
|
124
411
|
]);
|
|
125
412
|
// Each route may fail on its own without costing the others, but a failed
|
|
126
413
|
// sign-in means none of them answered: report that, not an empty course.
|
|
@@ -128,15 +415,21 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
128
415
|
if (result.status === "rejected" && isAuthUnavailable(result.reason))
|
|
129
416
|
throw result.reason;
|
|
130
417
|
}
|
|
418
|
+
// Ids of folders and quizzes the instructor hid or inactivated and that are
|
|
419
|
+
// therefore left out on purpose; their grade columns are not "uncovered".
|
|
420
|
+
const skippedIds = new Set();
|
|
131
421
|
// Process Dropbox folders
|
|
132
422
|
if (dropboxResult.status === "fulfilled") {
|
|
133
423
|
// D2L dropbox endpoint may return paged { Objects: [...] } or flat array
|
|
134
424
|
const dropboxRaw = dropboxResult.value;
|
|
135
425
|
const folders = Array.isArray(dropboxRaw) ? dropboxRaw : dropboxRaw.Objects ?? [];
|
|
136
426
|
for (const folder of folders) {
|
|
137
|
-
// Skip hidden folders
|
|
138
|
-
|
|
427
|
+
// Skip hidden folders. Their ids are remembered: the instructor hid the
|
|
428
|
+
// work, so its grade column must not resurface as a gradebook row.
|
|
429
|
+
if (folder.IsHidden) {
|
|
430
|
+
skippedIds.add(folder.Id);
|
|
139
431
|
continue;
|
|
432
|
+
}
|
|
140
433
|
// Fetch submissions for this folder. Some tenants deny students access
|
|
141
434
|
// to mysubmissions outright (403), in which case an empty list would be
|
|
142
435
|
// indistinguishable from genuinely unsubmitted work — track whether the
|
|
@@ -299,32 +592,50 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
299
592
|
}
|
|
300
593
|
// Process quizzes. Content discovery remains useful when the list route is
|
|
301
594
|
// forbidden or unavailable, so a failed list starts from an empty set.
|
|
302
|
-
|
|
595
|
+
const quizzes = [];
|
|
303
596
|
if (quizResult.status === "fulfilled") {
|
|
304
|
-
quizzes
|
|
597
|
+
quizzes.push(...quizResult.value.map((quiz) => ({ quiz, source: "quizList" })));
|
|
305
598
|
}
|
|
306
599
|
else {
|
|
307
600
|
log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
|
|
308
601
|
}
|
|
602
|
+
const knownQuizIds = () => new Set(quizzes.map((found) => found.quiz.QuizId).filter((id) => typeof id === "number"));
|
|
603
|
+
const knownQuizTitles = () => new Set(quizzes.map((found) => normalizedTitle(found.quiz.Name)));
|
|
604
|
+
const gradebookColumns = gradebookResult.status === "fulfilled"
|
|
605
|
+
? studentScoredColumns(gradebookResult.value)
|
|
606
|
+
: [];
|
|
607
|
+
let contentRecovery;
|
|
309
608
|
if (contentResult.status === "fulfilled") {
|
|
310
|
-
const
|
|
311
|
-
quizzes.push(...
|
|
609
|
+
const recovered = await recoverContentQuizzes(apiClient, courseId, contentResult.value.toc, knownQuizIds(), knownQuizTitles(), new Map(gradebookColumns.map((column) => [column.Id, column])));
|
|
610
|
+
quizzes.push(...recovered.found);
|
|
611
|
+
contentRecovery = { considered: recovered.considered, read: recovered.read };
|
|
312
612
|
}
|
|
313
613
|
else {
|
|
314
614
|
log("DEBUG", `Failed to fetch course content for quiz discovery in course ${courseId}`, contentResult.reason);
|
|
315
615
|
}
|
|
616
|
+
if (gradebookResult.status === "fulfilled") {
|
|
617
|
+
const folderIds = new Set([
|
|
618
|
+
...assignments.map((item) => item.id).filter((id) => typeof id === "number"),
|
|
619
|
+
...skippedIds,
|
|
620
|
+
]);
|
|
621
|
+
quizzes.push(...await recoverGradebookQuizzes(apiClient, courseId, gradebookColumns, folderIds, knownQuizIds(), contentResult.status === "fulfilled" ? contentQuizToolIds(contentResult.value.toc) : new Set(), new Set(quizzes.filter((found) => found.quiz.QuizId === null).map((found) => normalizedTitle(found.quiz.Name)))));
|
|
622
|
+
}
|
|
316
623
|
// Students on this tenant get 403 from /quizzes/{id}/attempts/. Once the
|
|
317
624
|
// first quiz of a course proves that, the remaining quizzes are not asked:
|
|
318
625
|
// the answer would be the same 403, at one wasted request each.
|
|
319
626
|
let attemptsForbidden = false;
|
|
320
|
-
for (const quiz of quizzes) {
|
|
321
|
-
// Skip inactive
|
|
322
|
-
|
|
627
|
+
for (const { quiz, source, topicId, exempt } of quizzes) {
|
|
628
|
+
// Skip quizzes the instructor marked inactive. A row that does not say
|
|
629
|
+
// is not inactive: only an explicit false hides a quiz.
|
|
630
|
+
if (quiz.IsActive === false) {
|
|
631
|
+
if (typeof quiz.QuizId === "number")
|
|
632
|
+
skippedIds.add(quiz.QuizId);
|
|
323
633
|
continue;
|
|
634
|
+
}
|
|
324
635
|
// Fetch quiz attempts. null means "not measured", which is different
|
|
325
636
|
// from an empty list, and the output says which one it was.
|
|
326
637
|
let attempts = null;
|
|
327
|
-
if (!attemptsForbidden && !quiz.ContentMetadataOnly) {
|
|
638
|
+
if (!attemptsForbidden && !quiz.ContentMetadataOnly && quiz.QuizId !== null) {
|
|
328
639
|
try {
|
|
329
640
|
const attemptsRaw = await apiClient.get(apiClient.le(courseId, `/quizzes/${quiz.QuizId}/attempts/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
330
641
|
// D2L attempts endpoint may return paged { Objects: [...] } or flat array
|
|
@@ -334,8 +645,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
334
645
|
if (isAuthUnavailable(error))
|
|
335
646
|
throw error;
|
|
336
647
|
if (error?.status === 404) {
|
|
337
|
-
//
|
|
338
|
-
attempts
|
|
648
|
+
// For a quiz the course's quiz list returned, 404 means no
|
|
649
|
+
// attempts yet: a measurement of zero. A quiz that list did not
|
|
650
|
+
// return is one the quiz API is already hiding from this student,
|
|
651
|
+
// so the same 404 may be that, and says nothing about attempts.
|
|
652
|
+
if (source === "quizList")
|
|
653
|
+
attempts = [];
|
|
654
|
+
else
|
|
655
|
+
log("DEBUG", `Attempts for quiz ${quiz.QuizId} answered 404 and it is not a listed quiz: leaving them unknown`);
|
|
339
656
|
}
|
|
340
657
|
else if (error?.status === 403) {
|
|
341
658
|
attemptsForbidden = true;
|
|
@@ -370,8 +687,17 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
370
687
|
const quizAssignment = {
|
|
371
688
|
type: "quiz",
|
|
372
689
|
id: quiz.QuizId,
|
|
690
|
+
...(quiz.QuizId === null && topicId !== undefined ? { topicId } : {}),
|
|
373
691
|
name: quiz.Name,
|
|
374
|
-
|
|
692
|
+
// A content quiz with no quiz id links to its content topic instead.
|
|
693
|
+
url: !baseUrl
|
|
694
|
+
? null
|
|
695
|
+
: quiz.QuizId !== null
|
|
696
|
+
? quizUrl(baseUrl, courseId, quiz.QuizId)
|
|
697
|
+
: topicId !== undefined ? contentTopicUrl(baseUrl, courseId, topicId) : null,
|
|
698
|
+
// Which route found this quiz: the course's quiz list, a quiz topic
|
|
699
|
+
// in the course content, or a gradebook column linked to it.
|
|
700
|
+
source,
|
|
375
701
|
instructions: descriptionHtml
|
|
376
702
|
? convertHtmlToMarkdown(descriptionHtml).markdown
|
|
377
703
|
: "",
|
|
@@ -404,17 +730,40 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
404
730
|
: null,
|
|
405
731
|
gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
|
|
406
732
|
hasPassword: Boolean(quiz.Password),
|
|
733
|
+
// Additive fields, each emitted only when it has something to say.
|
|
734
|
+
// availability is computed from the quiz's own start and end dates.
|
|
735
|
+
...availabilityField(quiz.StartDate, quiz.EndDate),
|
|
736
|
+
// A row built from the table of contents alone: the quiz's own record
|
|
737
|
+
// was refused, or the topic names no quiz id to ask for.
|
|
738
|
+
...(quiz.ContentMetadataOnly ? {
|
|
739
|
+
detailsStatus: "unavailable",
|
|
740
|
+
detailsStatusNote: DETAILS_UNAVAILABLE_NOTE,
|
|
741
|
+
} : {}),
|
|
742
|
+
// The topic's IsExempt: the user is exempt from the topic. Kept
|
|
743
|
+
// listed, since exempt does not mean it cannot be opened.
|
|
744
|
+
...(exempt ? { exempt: true } : {}),
|
|
407
745
|
};
|
|
408
746
|
assignments.push(quizAssignment);
|
|
409
747
|
}
|
|
410
748
|
// Process the gradebook last, once the fetched items are known.
|
|
411
749
|
if (gradebookResult.status === "fulfilled") {
|
|
412
|
-
assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl));
|
|
750
|
+
assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl, skippedIds));
|
|
413
751
|
}
|
|
414
752
|
else {
|
|
415
753
|
log("DEBUG", `Failed to fetch the gradebook for course ${courseId}`, gradebookResult.reason);
|
|
416
754
|
}
|
|
417
|
-
return
|
|
755
|
+
return {
|
|
756
|
+
assignments,
|
|
757
|
+
...quizCoverage({
|
|
758
|
+
// A 404 on the list means the course has no quiz tool: answered, and empty.
|
|
759
|
+
quizList: quizResult.status === "fulfilled" || quizResult.reason?.status === 404,
|
|
760
|
+
content: contentResult.status !== "fulfilled"
|
|
761
|
+
? "failed"
|
|
762
|
+
: contentResult.value.datesIncluded ? "full" : "dateRestricted",
|
|
763
|
+
gradebook: gradebookResult.status === "fulfilled",
|
|
764
|
+
contentRecovery,
|
|
765
|
+
}),
|
|
766
|
+
};
|
|
418
767
|
}
|
|
419
768
|
/**
|
|
420
769
|
* Gradebook columns describing work that no other route already offered.
|
|
@@ -429,18 +778,32 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
429
778
|
* A column with no id or no name is skipped rather than fatal: one malformed
|
|
430
779
|
* row should cost its own line, not the course's other heads-up rows.
|
|
431
780
|
*/
|
|
432
|
-
function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
781
|
+
function gradebookHeadsUp(raw, fetched, courseId, baseUrl, skippedIds = new Set()) {
|
|
433
782
|
if (!Array.isArray(raw))
|
|
434
783
|
return [];
|
|
435
|
-
|
|
784
|
+
// Fetched items, plus the hidden folders and inactive quizzes that were left
|
|
785
|
+
// out on purpose: a column of one of those is not work the tool missed.
|
|
786
|
+
const covered = new Set([
|
|
787
|
+
...fetched.map((item) => item.id).filter((id) => typeof id === "number"),
|
|
788
|
+
...skippedIds,
|
|
789
|
+
]);
|
|
790
|
+
// A content quiz that names no id cannot be matched to its column by id, so
|
|
791
|
+
// a column carrying the same title is taken to be that quiz.
|
|
792
|
+
const idlessQuizTitles = new Set(fetched
|
|
793
|
+
.filter((item) => item.type === "quiz" && item.id === null)
|
|
794
|
+
.map((item) => normalizedTitle(item.name)));
|
|
436
795
|
const rows = [];
|
|
437
796
|
for (const column of raw) {
|
|
438
|
-
if (!
|
|
797
|
+
if (!isStudentScored(column))
|
|
798
|
+
continue;
|
|
799
|
+
if (column.IsHidden === true)
|
|
439
800
|
continue;
|
|
440
801
|
if (covered.has(column.AssociatedTool?.ToolItemId))
|
|
441
802
|
continue;
|
|
442
803
|
if (typeof column.Id !== "number" || typeof column.Name !== "string")
|
|
443
804
|
continue;
|
|
805
|
+
if (idlessQuizTitles.has(normalizedTitle(column.Name)))
|
|
806
|
+
continue;
|
|
444
807
|
rows.push({
|
|
445
808
|
type: "gradeOnly",
|
|
446
809
|
id: column.Id,
|
|
@@ -462,7 +825,13 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
462
825
|
title: "Get Assignments",
|
|
463
826
|
annotations: { readOnlyHint: true },
|
|
464
827
|
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
|
|
465
|
-
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace."
|
|
828
|
+
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace. " +
|
|
829
|
+
"Quizzes come from the quiz list, the course content, and the gradebook; each quiz row says which in source. A quiz with id null was found only as a content topic. " +
|
|
830
|
+
"A quiz row with detailsStatus \"unavailable\" was shown in the course content but Brightspace would not return its details (it may be closed, not yet open, or limited to certain students), so its dates and attempts may be missing: point the user to its link. " +
|
|
831
|
+
"availability (notYetOpen, open, closed) is worked out from the quiz's own start and end dates and is left out when it has none. exempt: true means Brightspace marks the user exempt from that quiz's topic; the quiz is still listed. " +
|
|
832
|
+
"Each course also carries quizCoverage. \"complete\" means every quiz source answered, not that every quiz is guaranteed to be listed; \"incomplete\" means a quiz source could not be read, so the course may have quizzes not listed. " +
|
|
833
|
+
"Never conclude that a quiz was missed or not taken because it is absent, and never present the listed quizzes as all of them. " +
|
|
834
|
+
"A gradeOnly row named like a quiz is a quiz whose details could not be read.",
|
|
466
835
|
inputSchema: GetAssignmentsSchema,
|
|
467
836
|
}, async (args) => {
|
|
468
837
|
try {
|
|
@@ -472,9 +841,9 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
472
841
|
// Single course case
|
|
473
842
|
if (courseId) {
|
|
474
843
|
try {
|
|
475
|
-
const
|
|
476
|
-
log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
|
|
477
|
-
return toolResponse({ courseId,
|
|
844
|
+
const inventory = await fetchCourseInventory(apiClient, courseId, config.baseUrl);
|
|
845
|
+
log("INFO", `get_assignments: Retrieved ${inventory.assignments.length} assignments for course ${courseId}`);
|
|
846
|
+
return toolResponse({ courseId, ...inventory });
|
|
478
847
|
}
|
|
479
848
|
catch (error) {
|
|
480
849
|
// A pending sign-in is not an empty course: the routes never
|
|
@@ -520,11 +889,11 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
520
889
|
// Fetch assignments for each course (handle 403s gracefully)
|
|
521
890
|
const assignmentPromises = filteredEnrollments.map(async (item) => {
|
|
522
891
|
try {
|
|
523
|
-
const
|
|
892
|
+
const inventory = await fetchCourseInventory(apiClient, item.OrgUnit.Id, config.baseUrl);
|
|
524
893
|
return {
|
|
525
894
|
courseId: item.OrgUnit.Id,
|
|
526
895
|
courseName: item.OrgUnit.Name,
|
|
527
|
-
|
|
896
|
+
...inventory,
|
|
528
897
|
};
|
|
529
898
|
}
|
|
530
899
|
catch (error) {
|
|
@@ -557,7 +926,7 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
557
926
|
const pending = settled.filter((c) => c.authPending);
|
|
558
927
|
const courses = settled
|
|
559
928
|
.filter((c) => !c.authPending)
|
|
560
|
-
.map(({
|
|
929
|
+
.map(({ authPending: _authPending, authError: _authError, ...course }) => course);
|
|
561
930
|
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses ` +
|
|
562
931
|
`(out of ${enrollmentItems.length} enrolled${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
563
932
|
// Pending courses keep `assignments: []` so callers that read
|
package/build/tools/index.js
CHANGED
|
@@ -34,4 +34,5 @@ export { registerDownloadDropboxSubmissionFile } from "./download-dropbox-submis
|
|
|
34
34
|
// Re-export shared helpers and schemas for convenience
|
|
35
35
|
export { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
|
|
36
36
|
export { relayMfaChallenges, currentMfaAnnouncer } from "./mfa-relay.js";
|
|
37
|
+
export { acceptOmittedArguments } from "./omitted-arguments.js";
|
|
37
38
|
export * from "./schemas.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purdue Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { CallToolRequestSchema, GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
function withArguments(request) {
|
|
8
|
+
if (request.params?.arguments !== undefined)
|
|
9
|
+
return request;
|
|
10
|
+
return { ...request, params: { ...request.params, arguments: {} } };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Let a client leave `arguments` off a tools/call or prompts/get.
|
|
14
|
+
*
|
|
15
|
+
* The protocol makes the field optional, and some clients omit it for a tool
|
|
16
|
+
* that takes nothing. MCP SDK 1.26 through 1.31 check the missing value
|
|
17
|
+
* against the tool's schema as-is, so every tool whose inputs are all
|
|
18
|
+
* optional (get_server_info, sign_out, get_my_courses, ...) failed with
|
|
19
|
+
* "Input validation error: expected object, received undefined". SDK 1.32
|
|
20
|
+
* reads a missing value as `{}`, and this does the same on every version the
|
|
21
|
+
* dependency range admits. A tool with a required field still rejects the
|
|
22
|
+
* call, because `{}` fails its schema.
|
|
23
|
+
*
|
|
24
|
+
* Call this before registering any tool or prompt: McpServer installs its
|
|
25
|
+
* request handlers on the first registration, and only handlers installed
|
|
26
|
+
* after this call are wrapped.
|
|
27
|
+
*/
|
|
28
|
+
export function acceptOmittedArguments(server) {
|
|
29
|
+
const lowLevel = server.server;
|
|
30
|
+
const install = lowLevel.setRequestHandler.bind(lowLevel);
|
|
31
|
+
lowLevel.setRequestHandler = (schema, handler) => install(schema, schema === CallToolRequestSchema || schema === GetPromptRequestSchema
|
|
32
|
+
? (request, extra) => handler(withArguments(request), extra)
|
|
33
|
+
: handler);
|
|
34
|
+
}
|
|
@@ -29,6 +29,10 @@ export function assignmentUrl(baseUrl, courseId, folderId, groupId = 0) {
|
|
|
29
29
|
export function quizUrl(baseUrl, courseId, quizId) {
|
|
30
30
|
return `${trimBaseUrl(baseUrl)}/d2l/lms/quizzing/user/quiz_summary.d2l?qi=${quizId}&ou=${courseId}`;
|
|
31
31
|
}
|
|
32
|
+
/** Link to a topic in the course content viewer. */
|
|
33
|
+
export function contentTopicUrl(baseUrl, courseId, topicId) {
|
|
34
|
+
return `${trimBaseUrl(baseUrl)}/d2l/le/content/${courseId}/viewContent/${topicId}/View`;
|
|
35
|
+
}
|
|
32
36
|
/**
|
|
33
37
|
* Link to a course's own grades page. There is no per-column page a student
|
|
34
38
|
* may open, so every gradebook row in a course shares this one link.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.18",
|
|
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",
|