brightspace-mcp-server 4.0.5 → 4.0.7

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.
@@ -86,21 +86,23 @@ export function registerGetDropboxSubmissions(server, apiClient) {
86
86
  const truncated = total > limit;
87
87
  const kept = truncated ? rawSubmissions.slice(0, limit) : rawSubmissions;
88
88
  // Optionally fetch feedback for each kept submitter, a bounded
89
- // number at a time. Best-effort: a submitter with no feedback yet
90
- // simply has none in the map.
89
+ // number at a time. A 404 means no feedback yet, so that submitter
90
+ // simply has none in the map. Any other failure leaves their grading
91
+ // status unknown rather than "not graded", and the note names them.
91
92
  const feedbackMap = new Map();
93
+ const feedbackUnread = new Set();
92
94
  if (!ignoreFeedback) {
93
95
  await mapWithConcurrency(kept, FEEDBACK_FETCH_CONCURRENCY, async (sub) => {
96
+ const userId = sub.SubmittedBy.Identifier;
94
97
  try {
95
- const userId = sub.SubmittedBy.Identifier;
96
98
  const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/user/${userId}`);
97
99
  const fb = await apiClient.get(feedbackPath);
98
100
  feedbackMap.set(userId, fb);
99
101
  }
100
- catch {
101
- // No feedback yet, or this submitter's feedback route
102
- // otherwise failed — silently skip, matching the "not
103
- // graded yet" default below.
102
+ catch (error) {
103
+ if (!(error instanceof ApiError && error.status === 404)) {
104
+ feedbackUnread.add(userId);
105
+ }
104
106
  }
105
107
  });
106
108
  }
@@ -125,21 +127,41 @@ export function registerGetDropboxSubmissions(server, apiClient) {
125
127
  comment: sub.Comment?.Text ?? null,
126
128
  };
127
129
  if (!ignoreFeedback) {
128
- entry.feedbackStatus = feedback
129
- ? {
130
- isGraded: feedback.IsGraded,
131
- score: feedback.Score,
132
- maxScore,
133
- hasFeedbackText: !!feedback.Feedback?.Text,
134
- }
135
- : { isGraded: false, score: null, maxScore, hasFeedbackText: false };
130
+ entry.feedbackStatus = feedbackUnread.has(userId)
131
+ ? null
132
+ : feedback
133
+ ? {
134
+ isGraded: feedback.IsGraded,
135
+ score: feedback.Score,
136
+ maxScore,
137
+ hasFeedbackText: !!feedback.Feedback?.Text,
138
+ }
139
+ : { isGraded: false, score: null, maxScore, hasFeedbackText: false };
136
140
  }
137
141
  return entry;
138
142
  });
139
- // activeOnly: skip submissions that are already fully graded.
143
+ // activeOnly: skip submissions that are already fully graded. One
144
+ // whose feedback couldn't be read stays, since it may still need grading.
140
145
  const filtered = activeOnly && !ignoreFeedback
141
146
  ? submissions.filter((s) => !s.feedbackStatus?.isGraded)
142
147
  : submissions;
148
+ const unreadNames = kept
149
+ .filter((sub) => feedbackUnread.has(sub.SubmittedBy.Identifier))
150
+ .map((sub) => `"${sub.SubmittedBy.DisplayName || sub.SubmittedBy.Identifier}"`);
151
+ if (unreadNames.length > 0) {
152
+ log("INFO", "get_dropbox_submissions: Some submitters' feedback could not be read", {
153
+ courseId,
154
+ folderId,
155
+ userIds: [...feedbackUnread],
156
+ });
157
+ }
158
+ const notes = [
159
+ ...(truncated ? [`Showing ${kept.length} of ${total} submissions. Raise the limit argument to see more.`] : []),
160
+ ...(unreadNames.length > 0
161
+ ? [`Couldn't read the feedback for ${unreadNames.join(", ")}, so whether they are graded is unknown ` +
162
+ "(feedbackStatus is null)."]
163
+ : []),
164
+ ];
143
165
  log("INFO", `get_dropbox_submissions: ${filtered.length} submissions (of ${total} total) for folder ${folderId}`, { truncated });
144
166
  return toolResponse({
145
167
  courseId,
@@ -150,9 +172,7 @@ export function registerGetDropboxSubmissions(server, apiClient) {
150
172
  totalSubmissions: total,
151
173
  returnedSubmissions: filtered.length,
152
174
  truncated,
153
- ...(truncated
154
- ? { note: `Showing ${kept.length} of ${total} submissions. Raise the limit argument to see more.` }
155
- : {}),
175
+ ...(notes.length > 0 ? { note: notes.join(" ") } : {}),
156
176
  activeOnly,
157
177
  submissions: filtered,
158
178
  });
@@ -55,14 +55,20 @@ export function registerGetDropboxUserSubmissions(server, apiClient) {
55
55
  note: "No submissions found for this user in the specified folder.",
56
56
  });
57
57
  }
58
+ // A 404 means no feedback yet, matching the "not graded yet" default
59
+ // below. Any other failure leaves the grading status unknown.
58
60
  let feedback = null;
61
+ let feedbackUnread = false;
59
62
  if (!ignoreFeedback) {
60
63
  try {
61
64
  const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/user/${userId}`);
62
65
  feedback = await apiClient.get(feedbackPath);
63
66
  }
64
- catch {
65
- // No feedback yet — silently skip, matching the "not graded yet" default below.
67
+ catch (error) {
68
+ if (!(error instanceof ApiError && error.status === 404)) {
69
+ feedbackUnread = true;
70
+ log("INFO", "get_dropbox_user_submissions: Feedback could not be read", { courseId, folderId, userId });
71
+ }
66
72
  }
67
73
  }
68
74
  const submissions = rawSubmissions.map((sub) => ({
@@ -79,14 +85,16 @@ export function registerGetDropboxUserSubmissions(server, apiClient) {
79
85
  }));
80
86
  const feedbackStatus = ignoreFeedback
81
87
  ? undefined
82
- : feedback
83
- ? {
84
- isGraded: feedback.IsGraded,
85
- score: feedback.Score,
86
- hasFeedbackText: !!feedback.Feedback?.Text,
87
- feedbackText: feedback.Feedback?.Text ?? null,
88
- }
89
- : { isGraded: false, score: null, hasFeedbackText: false, feedbackText: null };
88
+ : feedbackUnread
89
+ ? null
90
+ : feedback
91
+ ? {
92
+ isGraded: feedback.IsGraded,
93
+ score: feedback.Score,
94
+ hasFeedbackText: !!feedback.Feedback?.Text,
95
+ feedbackText: feedback.Feedback?.Text ?? null,
96
+ }
97
+ : { isGraded: false, score: null, hasFeedbackText: false, feedbackText: null };
90
98
  log("INFO", `get_dropbox_user_submissions: ${submissions.length} submissions for user ${userId} in folder ${folderId}`);
91
99
  return toolResponse({
92
100
  courseId,
@@ -94,6 +102,9 @@ export function registerGetDropboxUserSubmissions(server, apiClient) {
94
102
  userId,
95
103
  submissions,
96
104
  feedbackStatus,
105
+ ...(feedbackUnread
106
+ ? { note: "Couldn't read this user's feedback, so whether this submission is graded is unknown (feedbackStatus is null)." }
107
+ : {}),
97
108
  });
98
109
  }
99
110
  catch (error) {
@@ -22,8 +22,9 @@ const QUIZ_FEEDBACK_NOTE = "The gradebook has no comment for this item, but it i
22
22
  "browser such as Respondus LockDown Browser. Open feedbackUrl to check; do not report " +
23
23
  "this item as having no feedback.";
24
24
  /**
25
- * The course's quizzes, or none when the route refuses or fails. A missing
26
- * quiz list only costs the feedback links, never the grades themselves.
25
+ * The course's quizzes, or null when the route refuses or fails. A missing
26
+ * quiz list only costs the feedback links, never the grades themselves, but
27
+ * the caller is told so a gradebook link isn't read as "not a quiz" (#278).
27
28
  */
28
29
  async function fetchQuizzes(apiClient, courseId) {
29
30
  try {
@@ -34,7 +35,7 @@ async function fetchQuizzes(apiClient, courseId) {
34
35
  }
35
36
  catch (error) {
36
37
  log("DEBUG", `get_my_grades: quiz list unavailable for course ${courseId}`, error);
37
- return [];
38
+ return null;
38
39
  }
39
40
  }
40
41
  /**
@@ -42,7 +43,7 @@ async function fetchQuizzes(apiClient, courseId) {
42
43
  * rows keep the quiz source URL and note; other rows link to the gradebook.
43
44
  */
44
45
  function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
45
- const quizByGradeItem = new Map(quizzes
46
+ const quizByGradeItem = new Map((quizzes ?? [])
46
47
  .filter((q) => q.GradeItemId != null)
47
48
  .map((q) => [String(q.GradeItemId), q.QuizId]));
48
49
  return gradeValues.map((gv) => {
@@ -65,6 +66,11 @@ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
65
66
  };
66
67
  });
67
68
  }
69
+ function quizzesUnavailableNotice(courseIds) {
70
+ return (`The quiz list for ${courseIds.length} course(s) (${courseIds.join(", ")}) could not be read, so ` +
71
+ "quiz-scored grade items there link to the gradebook and lack the quiz feedback note. Quiz " +
72
+ "feedback may still exist on the quiz's own page; do not report those items as having no feedback.");
73
+ }
68
74
  /**
69
75
  * Register get_my_grades tool
70
76
  */
@@ -122,8 +128,17 @@ export function registerGetMyGrades(server, apiClient, config) {
122
128
  throw error;
123
129
  }
124
130
  // Map to clean objects
125
- const grades = toGradeItems(gradeValues, await quizzesPromise, config.baseUrl, courseId);
131
+ const quizzes = await quizzesPromise;
132
+ const grades = toGradeItems(gradeValues, quizzes, config.baseUrl, courseId);
126
133
  log("INFO", `get_my_grades: Retrieved ${grades.length} grade items for course ${courseId}`);
134
+ if (quizzes === null) {
135
+ return toolResponse({
136
+ courseId,
137
+ grades,
138
+ quizzesUnavailable: true,
139
+ notice: quizzesUnavailableNotice([courseId]),
140
+ });
141
+ }
127
142
  return toolResponse({ courseId, grades });
128
143
  }
129
144
  // All courses case
@@ -160,6 +175,7 @@ export function registerGetMyGrades(server, apiClient, config) {
160
175
  courseId: item.OrgUnit.Id,
161
176
  courseName: item.OrgUnit.Name,
162
177
  grades,
178
+ ...(quizzes === null ? { quizzesUnavailable: true } : {}),
163
179
  };
164
180
  }
165
181
  catch (error) {
@@ -216,7 +232,12 @@ export function registerGetMyGrades(server, apiClient, config) {
216
232
  .map(({ failed: _failed, ...rest }) => rest);
217
233
  const courses = settled
218
234
  .filter((c) => !c.authPending && !c.restricted && !c.failed)
219
- .map(({ courseId, courseName, grades }) => ({ courseId, courseName, grades }));
235
+ .map(({ courseId, courseName, grades, quizzesUnavailable }) => ({
236
+ courseId,
237
+ courseName,
238
+ grades,
239
+ ...(quizzesUnavailable ? { quizzesUnavailable } : {}),
240
+ }));
220
241
  log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
221
242
  `${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled` +
222
243
  `${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""}` +
@@ -255,6 +276,9 @@ export function registerGetMyGrades(server, apiClient, config) {
255
276
  "so they are listed in failedCourses instead of courses. Do not report these courses as " +
256
277
  "having no grades: open each gradeUrl, or call get_my_grades again for that courseId.");
257
278
  }
279
+ const quizGaps = courses.filter((c) => c.quizzesUnavailable).map((c) => c.courseId);
280
+ if (quizGaps.length > 0)
281
+ notices.push(quizzesUnavailableNotice(quizGaps));
258
282
  if (notices.length > 0)
259
283
  response.notice = notices.join(" ");
260
284
  return toolResponse(response);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "4.0.5",
3
+ "version": "4.0.7",
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",