brightspace-mcp-server 3.11.9 → 3.11.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 { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetAssignmentRubricSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
- import { convertHtmlToMarkdown } from "../utils/html-converter.js";
9
+ import { feedbackText } from "../utils/feedback.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  // D2L's documented SCORING_M enumeration
12
12
  // (https://docs.valence.desire2learn.com/res/assessment.html). Only values
@@ -20,10 +20,7 @@ const SCORING_METHOD_NAMES = {
20
20
  };
21
21
  /** Rich text to plain markdown, or undefined when there is nothing to say. */
22
22
  function textOf(rt) {
23
- if (!rt)
24
- return undefined;
25
- const text = rt.Html ? convertHtmlToMarkdown(rt.Html).markdown : rt.Text ?? undefined;
26
- return text && text.trim().length > 0 ? text : undefined;
23
+ return feedbackText(rt) ?? undefined;
27
24
  }
28
25
  /** The groups a rubric carries, normalizing the grouped and flattened shapes. */
29
26
  function groupsOf(rubric) {
@@ -120,7 +117,7 @@ function levelNamesOf(rubric) {
120
117
  * The student's own graded outcome on this rubric, resolved against the
121
118
  * rubric's own criterion/level names rather than re-sent by the assessment.
122
119
  */
123
- function toOutputAssessment(rubric, assessment) {
120
+ export function toOutputAssessment(rubric, assessment) {
124
121
  const criterionNames = criterionNamesOf(rubric);
125
122
  const levelNames = levelNamesOf(rubric);
126
123
  const out = { criteria: [] };
@@ -13,6 +13,8 @@ import { applyCourseFilter } from "../utils/course-filter.js";
13
13
  import { 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
+ import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
17
+ import { toOutputAssessment } from "./get-assignment-rubric.js";
16
18
  /** The display HTML of a quiz rich-text field, nested shape or flat. */
17
19
  function richTextHtml(field) {
18
20
  if (!field)
@@ -154,13 +156,18 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
154
156
  }
155
157
  // Fetch feedback independently of submissions
156
158
  let feedback = null;
159
+ let feedbackStatus = "unavailable";
157
160
  try {
158
161
  feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
162
+ feedbackStatus = feedback ? "retrieved" : "unavailable";
159
163
  }
160
164
  catch (error) {
161
165
  if (isAuthUnavailable(error))
162
166
  throw error;
163
- // 404/403 means no feedback available (or no access) - that's fine
167
+ // Permission failures, missing API records, and transport failures
168
+ // do not establish whether published feedback exists in the UI.
169
+ feedbackStatus = error?.status === 403 ? "restricted"
170
+ : error?.status === 404 ? "unavailable" : "error";
164
171
  if (error?.status !== 404 && error?.status !== 403) {
165
172
  log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
166
173
  }
@@ -176,6 +183,15 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
176
183
  .filter((l) => Boolean(l.Href))
177
184
  .map((l) => ({ name: l.LinkName ?? l.Title ?? l.Href ?? null, url: l.Href }));
178
185
  // Build assignment object
186
+ const overallFeedback = feedbackText(feedback?.Feedback);
187
+ const rubricAssessments = (feedback?.RubricAssessments ?? []).map((assessment) => {
188
+ const rubric = folder.Assessment?.Rubrics?.find((r) => r.RubricId === assessment.RubricId);
189
+ return {
190
+ rubricId: assessment.RubricId,
191
+ ...toOutputAssessment((rubric ?? { RubricId: assessment.RubricId, Name: "" }), assessment),
192
+ };
193
+ });
194
+ const commentsRetrieved = Boolean(overallFeedback || rubricAssessments.some((rubric) => rubric.feedback || rubric.criteria.some((criterion) => criterion.feedback)));
179
195
  const assignment = {
180
196
  type: "assignment",
181
197
  id: folder.Id,
@@ -227,11 +243,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
227
243
  feedback: feedback
228
244
  ? {
229
245
  score: feedback.Score,
230
- feedback: feedback.Feedback?.Html
231
- ? convertHtmlToMarkdown(feedback.Feedback.Html).markdown
232
- : null,
246
+ feedback: overallFeedback,
247
+ rubricAssessments,
233
248
  }
234
249
  : null,
250
+ feedbackStatus,
251
+ feedbackCommentsStatus: commentsRetrieved ? "retrieved" : "unavailable",
252
+ feedbackUrl: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
253
+ ...(commentsRetrieved ? {} : { feedbackStatusNote: FEEDBACK_UNAVAILABLE_NOTE }),
235
254
  };
236
255
  assignments.push(assignment);
237
256
  }
@@ -10,6 +10,7 @@ import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPend
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
13
+ import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
13
14
  /**
14
15
  * The quiz a grade row is scored from can hold feedback the gradebook does
15
16
  * not, and the student API cannot read it: quiz attempts are refused to
@@ -35,15 +36,15 @@ async function fetchQuizzes(apiClient, courseId) {
35
36
  }
36
37
  }
37
38
  /**
38
- * Clean grade rows. Rows scored from a quiz also carry feedbackUrl, and a
39
- * feedbackNote when the gradebook comment is empty; other rows are unchanged.
39
+ * Normalize public comments and report their retrieval status. Quiz-linked
40
+ * rows keep the quiz source URL and note; other rows link to the gradebook.
40
41
  */
41
42
  function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
42
43
  const quizByGradeItem = new Map(quizzes
43
44
  .filter((q) => q.GradeItemId != null)
44
45
  .map((q) => [String(q.GradeItemId), q.QuizId]));
45
46
  return gradeValues.map((gv) => {
46
- const comments = gv.Comments?.Text || null;
47
+ const comments = feedbackText(gv.Comments);
47
48
  const quizId = quizByGradeItem.get(String(gv.GradeObjectIdentifier));
48
49
  return {
49
50
  name: gv.GradeObjectName,
@@ -52,12 +53,13 @@ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
52
53
  pointsDenominator: gv.PointsDenominator,
53
54
  weightedNumerator: gv.WeightedNumerator,
54
55
  weightedDenominator: gv.WeightedDenominator,
56
+ gradeObjectId: gv.GradeObjectIdentifier,
55
57
  comments,
58
+ feedbackStatus: comments ? "retrieved" : "unavailable",
59
+ feedbackUrl: quizId === undefined ? gradebookUrl(baseUrl, courseId) : quizUrl(baseUrl, courseId, quizId),
60
+ ...(comments ? {} : { feedbackStatusNote: FEEDBACK_UNAVAILABLE_NOTE }),
56
61
  lastModified: gv.LastModified,
57
- ...(quizId === undefined ? {} : {
58
- feedbackUrl: quizUrl(baseUrl, courseId, quizId),
59
- ...(comments ? {} : { feedbackNote: QUIZ_FEEDBACK_NOTE }),
60
- }),
62
+ ...(quizId !== undefined && !comments ? { feedbackNote: QUIZ_FEEDBACK_NOTE } : {}),
61
63
  };
62
64
  });
63
65
  }
@@ -0,0 +1,12 @@
1
+ /** Public instructor feedback only; never substitute private comments. */
2
+ import { convertHtmlToMarkdown } from "./html-converter.js";
3
+ export function feedbackText(value) {
4
+ if (!value)
5
+ return null;
6
+ const markdown = value.Html ? convertHtmlToMarkdown(value.Html).markdown : null;
7
+ if (markdown?.trim())
8
+ return markdown;
9
+ return value.Text?.trim() ? value.Text : null;
10
+ }
11
+ export const FEEDBACK_UNAVAILABLE_NOTE = "Brightspace did not return instructor comments through this API. Published feedback may still " +
12
+ "be available in Brightspace; do not interpret null as no feedback. Check the source URL.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.9",
3
+ "version": "3.11.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",