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:
|
|
554
|
-
? "
|
|
555
|
-
:
|
|
556
|
-
? "
|
|
557
|
-
:
|
|
558
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|