brightspace-mcp-server 5.0.2 → 5.0.4

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 CHANGED
@@ -69,14 +69,14 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
69
69
  |-------|-----|
70
70
  | 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) |
71
71
  | Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
72
- | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
72
+ | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" · "Did I turn in HW 3?" (if Brightspace won't give students their own submission list, the assignment's feedback page is checked instead; when neither shows a submission it's reported as unknown, never as missing) |
73
73
  | 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) |
74
74
  | Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
75
75
  | 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 |
76
76
  | 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) |
77
77
  | People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
78
78
  | Discussions | "Summarize the latest posts in the final project thread" |
79
- | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
79
+ | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube. When YouTube asks for a sign-in or a "not a bot" check, the answer says so, with the video link and how to get the transcript yourself: open the link in your browser, choose "Show transcript", and paste it in. The server never gets past that check for you) |
80
80
  | Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
81
81
  | For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students get a clear "instructor access required" note for these, but can read their own released feedback, below) |
82
82
  | Signing out | "Sign me out of Brightspace" (your AI app may ask you to confirm first; details under [When it asks you to sign in](#when-it-asks-you-to-sign-in)) |
@@ -390,6 +390,23 @@ function quizCoverage(answered) {
390
390
  export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
391
391
  return (await fetchCourseInventory(apiClient, courseId, baseUrl)).assignments;
392
392
  }
393
+ /**
394
+ * True when a student's own dropbox feedback page lists at least one
395
+ * submission. Brightspace renders each submission as a row of its
396
+ * submitted-files grid: the numeric submission ID in a <label>, then the
397
+ * submitted file links (span.dfl), then the date. This reads that structure,
398
+ * not the column titles, which are translated and are present even as headers
399
+ * of an otherwise empty grid. A page with "No submissions made for this
400
+ * assignment", a login page, or any other page has no such row.
401
+ */
402
+ function feedbackPageListsSubmission(html) {
403
+ for (const row of html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
404
+ const cells = row[1];
405
+ if (/^\s*<td\b[^>]*>\s*<label>\s*\d+\s*<\/label>\s*<\/td>/i.test(cells) && /class="dfl"/i.test(cells))
406
+ return true;
407
+ }
408
+ return false;
409
+ }
393
410
  /** A course's assignments and quizzes, with whether its quiz list is complete. */
394
411
  async function fetchCourseInventory(apiClient, courseId, baseUrl) {
395
412
  const assignments = [];
@@ -456,6 +473,22 @@ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
456
473
  log("DEBUG", `Could not determine submission state for folder ${folder.Id}`, error);
457
474
  }
458
475
  }
476
+ // When the API refuses the list, the student's own feedback page may
477
+ // still show it. Only a listed submission counts: a page with none, or
478
+ // no page at all, leaves the state unknown rather than "not submitted".
479
+ let submissionSource = null;
480
+ if (!submissionsKnown && (folder.GroupTypeId === null || folder.GroupTypeId === undefined)) {
481
+ try {
482
+ const html = await apiClient.getPage(`/d2l/lms/dropbox/user/folder_user_view_feedback.d2l?db=${folder.Id}&grpid=0&ou=${courseId}`);
483
+ if (html !== null && feedbackPageListsSubmission(html))
484
+ submissionSource = "feedbackPage";
485
+ }
486
+ catch (error) {
487
+ if (isAuthUnavailable(error))
488
+ throw error;
489
+ log("DEBUG", `Feedback-page fallback failed for folder ${folder.Id}`, error);
490
+ }
491
+ }
459
492
  // Fetch feedback independently of submissions
460
493
  let feedback = null;
461
494
  let feedbackStatus = "unavailable";
@@ -550,12 +583,18 @@ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
550
583
  // when nothing is known, but submissionStatus tells apart "nothing
551
584
  // was submitted" (404, a known answer) from "the answer is denied"
552
585
  // (any other error), so a caller never states real work as missing.
553
- submissionStatus: !submissionsKnown
554
- ? "unknown"
555
- : submissions.length > 0
556
- ? "submitted"
557
- : "not_submitted",
558
- ...(submissionsKnown ? {} : {
586
+ submissionStatus: submissionSource
587
+ ? "submitted"
588
+ : !submissionsKnown
589
+ ? "unknown"
590
+ : submissions.length > 0
591
+ ? "submitted"
592
+ : "not_submitted",
593
+ ...(submissionSource ? {
594
+ submissionSource,
595
+ submissionStatusNote: "Brightspace did not return this assignment's submission list, but its feedback page lists a " +
596
+ "submission, so it was submitted. File details are only shown on that page.",
597
+ } : submissionsKnown ? {} : {
559
598
  submissionStatusNote: "Brightspace did not return this assignment's submission list, so whether it was submitted " +
560
599
  "is unknown. Do not report it as missing or unsubmitted - tell the user to check Brightspace directly.",
561
600
  }),
@@ -11,7 +11,7 @@ import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId, } from "
11
11
  import { readLtiLaunchPage, toBrightspacePath } from "../utils/transcript/lti.js";
12
12
  import { getKalturaTranscript } from "../utils/transcript/kaltura.js";
13
13
  import { getYouTubeTranscript } from "../utils/transcript/youtube.js";
14
- import { NoTranscriptError, TranscriptFetchError } from "../utils/transcript/errors.js";
14
+ import { NoTranscriptError, TranscriptFetchError, } from "../utils/transcript/errors.js";
15
15
  import { log } from "../utils/logger.js";
16
16
  import { stripD2lSessionParams } from "../utils/session-params.js";
17
17
  const UNSUPPORTED_PLATFORM_LABEL = {
@@ -48,6 +48,24 @@ async function followLtiLaunch(apiClient, path) {
48
48
  }
49
49
  return null;
50
50
  }
51
+ /**
52
+ * What a person can do about each failure. A sign-in or bot check is never
53
+ * worked around here: the way past it is the user's own browser, and the
54
+ * result says plainly that nothing was read.
55
+ */
56
+ function transcriptRecovery(reason, videoUrl) {
57
+ switch (reason) {
58
+ case "sign_in_required":
59
+ case "access_blocked":
60
+ return (`Open ${videoUrl} in your own browser and complete any sign-in or "not a bot" check it shows. ` +
61
+ 'Then choose "Show transcript" (on YouTube, below the video description) and paste the text into ' +
62
+ "this conversation. This server does not get past those checks, and no transcript was read from this video.");
63
+ case "unavailable":
64
+ return `Open ${videoUrl} in your browser to see whether the video was removed or made private.`;
65
+ case "fetch_failed":
66
+ return `Try again in a moment. If it keeps failing, open ${videoUrl} in your browser.`;
67
+ }
68
+ }
51
69
  async function fetchTranscript(platform, videoUrl, fetchImpl) {
52
70
  switch (platform) {
53
71
  case "kaltura": {
@@ -55,6 +73,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
55
73
  if (!ids) {
56
74
  return {
57
75
  failed: `Could not find a Kaltura entry ID and partner ID in this URL: ${stripD2lSessionParams(videoUrl)}`,
76
+ reason: "fetch_failed",
58
77
  };
59
78
  }
60
79
  try {
@@ -67,6 +86,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
67
86
  return {
68
87
  failed: `${error.message} This can happen when the video needs an authenticated Kaltura ` +
69
88
  "session rather than an anonymous one. Open it in Brightspace directly.",
89
+ reason: error.reason,
70
90
  };
71
91
  }
72
92
  throw error;
@@ -75,7 +95,10 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
75
95
  case "youtube": {
76
96
  const videoId = extractYouTubeVideoId(videoUrl);
77
97
  if (!videoId) {
78
- return { failed: `Could not find a YouTube video ID in this URL: ${stripD2lSessionParams(videoUrl)}` };
98
+ return {
99
+ failed: `Could not find a YouTube video ID in this URL: ${stripD2lSessionParams(videoUrl)}`,
100
+ reason: "fetch_failed",
101
+ };
79
102
  }
80
103
  try {
81
104
  return { result: await getYouTubeTranscript(videoId, fetchImpl) };
@@ -84,7 +107,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
84
107
  if (error instanceof NoTranscriptError)
85
108
  return { noTranscript: error.message };
86
109
  if (error instanceof TranscriptFetchError)
87
- return { failed: error.message };
110
+ return { failed: error.message, reason: error.reason };
88
111
  throw error;
89
112
  }
90
113
  }
@@ -114,7 +137,8 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
114
137
  "Call it with courseId and topicId from get_course_content (typeFilter: 'video' or 'other'), or with videoUrl " +
115
138
  "directly if you already have the link. Returns transcript text with timestamps, plus title and duration when " +
116
139
  "available. Currently supports Kaltura (e.g. BoilerCast, including its Brightspace LTI links) and YouTube; other platforms return a clear message " +
117
- "naming what isn't supported yet. Each call returns at most maxChars characters (up to 100000); to read a whole " +
140
+ "naming what isn't supported yet. A failure returns its message plus a JSON block with reason (e.g. sign_in_required " +
141
+ "for a YouTube bot check) and recovery steps for the user; never present a failed call as a read transcript. Each call returns at most maxChars characters (up to 100000); to read a whole " +
118
142
  "transcript, call again with offset set to the returned nextOffset until truncated is false. Read only — this " +
119
143
  "never marks the video as watched.",
120
144
  inputSchema: GetVideoTranscriptSchema,
@@ -178,11 +202,27 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
178
202
  videoUrl: publicUrl,
179
203
  platform,
180
204
  hasTranscript: false,
205
+ reason: "no_captions",
181
206
  message: outcome.noTranscript,
182
207
  });
183
208
  }
184
209
  if ("failed" in outcome) {
185
- return errorResponse(outcome.failed);
210
+ // The message stays the first block, as it always was; the second
211
+ // carries the same failure in a shape a caller can branch on.
212
+ const failure = errorResponse(outcome.failed);
213
+ failure.content.push({
214
+ type: "text",
215
+ text: JSON.stringify({
216
+ courseId,
217
+ topicId,
218
+ videoUrl: publicUrl,
219
+ platform,
220
+ hasTranscript: false,
221
+ reason: outcome.reason,
222
+ recovery: transcriptRecovery(outcome.reason, publicUrl),
223
+ }),
224
+ });
225
+ return failure;
186
226
  }
187
227
  const { result } = outcome;
188
228
  const fullText = cuesToText(result.cues);
@@ -12,8 +12,10 @@ export class NoTranscriptError extends Error {
12
12
  }
13
13
  /** The platform's API could not be reached or rejected the request. */
14
14
  export class TranscriptFetchError extends Error {
15
- constructor(message) {
15
+ reason;
16
+ constructor(message, reason = "fetch_failed") {
16
17
  super(message);
18
+ this.reason = reason;
17
19
  this.name = "TranscriptFetchError";
18
20
  }
19
21
  }
@@ -51,10 +51,10 @@ function trackLabel(track) {
51
51
  function playabilityFailure(status, reason) {
52
52
  const detail = reason ? ` YouTube said: ${reason}` : "";
53
53
  if (status === "LOGIN_REQUIRED") {
54
- return new TranscriptFetchError(`YouTube would not serve this video without a sign-in.${detail}`);
54
+ return new TranscriptFetchError(`YouTube would not serve this video without a sign-in.${detail}`, "sign_in_required");
55
55
  }
56
56
  if (status === "UNPLAYABLE" || status === "ERROR") {
57
- return new TranscriptFetchError(`YouTube reports this video as unavailable.${detail}`);
57
+ return new TranscriptFetchError(`YouTube reports this video as unavailable.${detail}`, "unavailable");
58
58
  }
59
59
  return new TranscriptFetchError(`YouTube refused to describe this video (${status}).${detail}`);
60
60
  }
@@ -140,7 +140,7 @@ export async function getYouTubeTranscript(videoId, fetchImpl, language = "en")
140
140
  }
141
141
  if (needsProofOfOrigin(track.baseUrl)) {
142
142
  throw new TranscriptFetchError("YouTube issued this caption track behind a proof-of-origin check this server cannot answer, " +
143
- "so the captions could not be downloaded. The video may still have captions.");
143
+ "so the captions could not be downloaded. The video may still have captions.", "access_blocked");
144
144
  }
145
145
  const res = await fetchImpl(captionUrlAsVtt(track.baseUrl));
146
146
  if (!res.ok) {
@@ -152,7 +152,7 @@ export async function getYouTubeTranscript(videoId, fetchImpl, language = "en")
152
152
  // users looking for a transcript that was there all along.
153
153
  if (!body.trim()) {
154
154
  throw new TranscriptFetchError("YouTube listed a caption track for this video but served an empty response for it. " +
155
- "This is usually a temporary block rather than a missing transcript; try again.");
155
+ "This is usually a temporary block rather than a missing transcript; try again.", "access_blocked");
156
156
  }
157
157
  const { format, cues } = parseCaptions(body);
158
158
  if (cues.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
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",