brightspace-mcp-server 3.10.11 → 3.10.15
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 +3 -2
- package/build/api/client.js +22 -0
- package/build/tools/get-my-grades.js +59 -24
- package/build/tools/get-syllabus.js +51 -1
- package/build/tools/get-video-transcript.js +36 -1
- package/build/utils/transcript/lti.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,15 +65,16 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
65
65
|
|
|
66
66
|
| About | Try |
|
|
67
67
|
|-------|-----|
|
|
68
|
-
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" |
|
|
68
|
+
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "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
71
|
| Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
|
|
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
|
+
| 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) |
|
|
74
75
|
| People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
|
|
75
76
|
| Discussions | "Summarize the latest posts in the final project thread" |
|
|
76
|
-
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube) |
|
|
77
|
+
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
|
|
77
78
|
| Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
|
|
78
79
|
| 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 see a clear "instructor access required" note) |
|
|
79
80
|
|
package/build/api/client.js
CHANGED
|
@@ -300,6 +300,28 @@ export class D2LApiClient {
|
|
|
300
300
|
const resolved = await this.resolvePath(path);
|
|
301
301
|
return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token));
|
|
302
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Fetch a Brightspace web page (not an API route) as the signed-in user.
|
|
305
|
+
* Pages check the session cookie and ignore a Bearer token, so this sends
|
|
306
|
+
* the stored cookie. Returns null when no cookie is stored or the page
|
|
307
|
+
* answers with the login redirect: it never starts a login, since a page
|
|
308
|
+
* read by a read-only tool is not worth an MFA prompt.
|
|
309
|
+
*/
|
|
310
|
+
async getPage(path) {
|
|
311
|
+
const token = await this.tokenManager.getToken();
|
|
312
|
+
if (!token?.cookieHeader)
|
|
313
|
+
return null;
|
|
314
|
+
const cookieToken = { ...token, accessToken: `cookie:${token.cookieHeader}` };
|
|
315
|
+
try {
|
|
316
|
+
const response = await this.retrying(() => this.throttled(() => this.makeRawRequest(path, cookieToken)));
|
|
317
|
+
return await response.text();
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error instanceof ApiError && error.status === 401)
|
|
321
|
+
return null;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
303
325
|
/** One HTTP refresh and at most one browser login per caller. */
|
|
304
326
|
async withAuthentication(path, request) {
|
|
305
327
|
let token = await this.tokenManager.getToken();
|
|
@@ -9,7 +9,58 @@ import { GetMyGradesSchema, } from "./schemas.js";
|
|
|
9
9
|
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
|
-
import { gradebookUrl } from "../utils/deep-links.js";
|
|
12
|
+
import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
13
|
+
/**
|
|
14
|
+
* The quiz a grade row is scored from can hold feedback the gradebook does
|
|
15
|
+
* not, and the student API cannot read it: quiz attempts are refused to
|
|
16
|
+
* students, and some quizzes only show feedback inside a restricted viewer.
|
|
17
|
+
*/
|
|
18
|
+
const QUIZ_FEEDBACK_NOTE = "The gradebook has no comment for this item, but it is scored from a quiz, and quiz " +
|
|
19
|
+
"feedback lives on the quiz's own submissions page, which Brightspace does not expose " +
|
|
20
|
+
"to the student API. Feedback may exist there, sometimes viewable only in a restricted " +
|
|
21
|
+
"browser such as Respondus LockDown Browser. Open feedbackUrl to check; do not report " +
|
|
22
|
+
"this item as having no feedback.";
|
|
23
|
+
/**
|
|
24
|
+
* The course's quizzes, or none when the route refuses or fails. A missing
|
|
25
|
+
* quiz list only costs the feedback links, never the grades themselves.
|
|
26
|
+
*/
|
|
27
|
+
async function fetchQuizzes(apiClient, courseId) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
30
|
+
return Array.isArray(raw) ? raw : raw?.Objects ?? [];
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
log("DEBUG", `get_my_grades: quiz list unavailable for course ${courseId}`, error);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
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.
|
|
40
|
+
*/
|
|
41
|
+
function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
|
|
42
|
+
const quizByGradeItem = new Map(quizzes
|
|
43
|
+
.filter((q) => q.GradeItemId != null)
|
|
44
|
+
.map((q) => [String(q.GradeItemId), q.QuizId]));
|
|
45
|
+
return gradeValues.map((gv) => {
|
|
46
|
+
const comments = gv.Comments?.Text || null;
|
|
47
|
+
const quizId = quizByGradeItem.get(String(gv.GradeObjectIdentifier));
|
|
48
|
+
return {
|
|
49
|
+
name: gv.GradeObjectName,
|
|
50
|
+
displayGrade: gv.DisplayedGrade,
|
|
51
|
+
pointsNumerator: gv.PointsNumerator,
|
|
52
|
+
pointsDenominator: gv.PointsDenominator,
|
|
53
|
+
weightedNumerator: gv.WeightedNumerator,
|
|
54
|
+
weightedDenominator: gv.WeightedDenominator,
|
|
55
|
+
comments,
|
|
56
|
+
lastModified: gv.LastModified,
|
|
57
|
+
...(quizId === undefined ? {} : {
|
|
58
|
+
feedbackUrl: quizUrl(baseUrl, courseId, quizId),
|
|
59
|
+
...(comments ? {} : { feedbackNote: QUIZ_FEEDBACK_NOTE }),
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
13
64
|
/**
|
|
14
65
|
* Register get_my_grades tool
|
|
15
66
|
*/
|
|
@@ -26,6 +77,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
26
77
|
// Single course case
|
|
27
78
|
if (courseId) {
|
|
28
79
|
const path = apiClient.le(courseId, "/grades/values/myGradeValues/");
|
|
80
|
+
const quizzesPromise = fetchQuizzes(apiClient, courseId);
|
|
29
81
|
let gradeValues;
|
|
30
82
|
try {
|
|
31
83
|
gradeValues = await apiClient.get(path, {
|
|
@@ -65,16 +117,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
65
117
|
throw error;
|
|
66
118
|
}
|
|
67
119
|
// Map to clean objects
|
|
68
|
-
const grades = gradeValues.
|
|
69
|
-
name: gv.GradeObjectName,
|
|
70
|
-
displayGrade: gv.DisplayedGrade,
|
|
71
|
-
pointsNumerator: gv.PointsNumerator,
|
|
72
|
-
pointsDenominator: gv.PointsDenominator,
|
|
73
|
-
weightedNumerator: gv.WeightedNumerator,
|
|
74
|
-
weightedDenominator: gv.WeightedDenominator,
|
|
75
|
-
comments: gv.Comments?.Text || null,
|
|
76
|
-
lastModified: gv.LastModified,
|
|
77
|
-
}));
|
|
120
|
+
const grades = toGradeItems(gradeValues, await quizzesPromise, config.baseUrl, courseId);
|
|
78
121
|
log("INFO", `get_my_grades: Retrieved ${grades.length} grade items for course ${courseId}`);
|
|
79
122
|
return toolResponse({ courseId, grades });
|
|
80
123
|
}
|
|
@@ -102,19 +145,11 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
102
145
|
const gradePromises = filteredEnrollments.map(async (item) => {
|
|
103
146
|
try {
|
|
104
147
|
const path = apiClient.le(item.OrgUnit.Id, "/grades/values/myGradeValues/");
|
|
105
|
-
const gradeValues = await
|
|
106
|
-
ttl: DEFAULT_CACHE_TTLS.grades,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
displayGrade: gv.DisplayedGrade,
|
|
111
|
-
pointsNumerator: gv.PointsNumerator,
|
|
112
|
-
pointsDenominator: gv.PointsDenominator,
|
|
113
|
-
weightedNumerator: gv.WeightedNumerator,
|
|
114
|
-
weightedDenominator: gv.WeightedDenominator,
|
|
115
|
-
comments: gv.Comments?.Text || null,
|
|
116
|
-
lastModified: gv.LastModified,
|
|
117
|
-
}));
|
|
148
|
+
const [gradeValues, quizzes] = await Promise.all([
|
|
149
|
+
apiClient.get(path, { ttl: DEFAULT_CACHE_TTLS.grades }),
|
|
150
|
+
fetchQuizzes(apiClient, item.OrgUnit.Id),
|
|
151
|
+
]);
|
|
152
|
+
const grades = toGradeItems(gradeValues, quizzes, config.baseUrl, item.OrgUnit.Id);
|
|
118
153
|
return {
|
|
119
154
|
restricted: false,
|
|
120
155
|
courseId: item.OrgUnit.Id,
|
|
@@ -13,13 +13,61 @@ import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
|
13
13
|
import { log } from "../utils/logger.js";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import fs from "node:fs/promises";
|
|
16
|
+
const SYLLABUS_PATTERN = /syllab/i;
|
|
17
|
+
const LTI_LAUNCH_PATTERN = /[?&]type=lti\b|\/d2l\/le\/lti\//i;
|
|
18
|
+
const EXTERNAL_SYLLABUS_NOTE = "This course links its syllabus through an external LTI tool. The server lists these sources but cannot launch LTI tools, so their contents are not included; open the link in Brightspace to read it.";
|
|
19
|
+
/** Launch URL without its query string, which can carry launch parameters. */
|
|
20
|
+
function stripQuery(url) {
|
|
21
|
+
return url.split(/[?#]/)[0];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pick the syllabus-related LTI sources out of the course's LTI links and
|
|
25
|
+
* content tree. Pure: the caller fetches, this only decides.
|
|
26
|
+
*/
|
|
27
|
+
function findExternalSyllabusSources(ltiLinks, toc) {
|
|
28
|
+
const sources = ltiLinks
|
|
29
|
+
.filter((l) => l.IsVisible !== false && (SYLLABUS_PATTERN.test(l.Title) || SYLLABUS_PATTERN.test(l.Url)))
|
|
30
|
+
.map((l) => ({ title: l.Title, location: "lti-link", url: stripQuery(l.Url) }));
|
|
31
|
+
const walk = (modules) => {
|
|
32
|
+
for (const m of modules) {
|
|
33
|
+
for (const t of m.Topics ?? []) {
|
|
34
|
+
if (t.Url && LTI_LAUNCH_PATTERN.test(t.Url) && SYLLABUS_PATTERN.test(t.Title)) {
|
|
35
|
+
sources.push({ title: t.Title, location: "content", url: t.Url });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
walk(m.Modules ?? []);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
walk(toc);
|
|
42
|
+
return sources;
|
|
43
|
+
}
|
|
44
|
+
/** Fetch a lookup that students may not be permitted to make; failure means "none". */
|
|
45
|
+
async function fetchOrEmpty(fetch, fallback, what) {
|
|
46
|
+
try {
|
|
47
|
+
return await fetch();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
log("DEBUG", `get_syllabus: could not read ${what}`, error);
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function discoverExternalSyllabusSources(apiClient, courseId) {
|
|
55
|
+
const [ltiLinks, toc] = await Promise.all([
|
|
56
|
+
fetchOrEmpty(() => apiClient.get(apiClient.leGlobal(`/lti/link/${courseId}/`), { ttl: DEFAULT_CACHE_TTLS.courseContent }), [], "LTI links"),
|
|
57
|
+
fetchOrEmpty(() => apiClient.get(apiClient.le(courseId, "/content/toc"), { ttl: DEFAULT_CACHE_TTLS.courseContent }), {}, "content table of contents"),
|
|
58
|
+
]);
|
|
59
|
+
const sources = findExternalSyllabusSources(ltiLinks ?? [], toc?.Modules ?? []);
|
|
60
|
+
return sources.length > 0
|
|
61
|
+
? { externalSyllabusSources: sources, externalSyllabusNote: EXTERNAL_SYLLABUS_NOTE }
|
|
62
|
+
: {};
|
|
63
|
+
}
|
|
16
64
|
/**
|
|
17
65
|
* Register get_syllabus tool
|
|
18
66
|
*/
|
|
19
67
|
export function registerGetSyllabus(server, apiClient) {
|
|
20
68
|
server.registerTool("get_syllabus", {
|
|
21
69
|
title: "Get Course Syllabus",
|
|
22
|
-
description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
|
|
70
|
+
description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
|
|
23
71
|
inputSchema: GetSyllabusSchema,
|
|
24
72
|
}, async (args) => {
|
|
25
73
|
try {
|
|
@@ -55,6 +103,7 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
55
103
|
description: null,
|
|
56
104
|
hasAttachment: false,
|
|
57
105
|
message: "No syllabus/overview found for this course.",
|
|
106
|
+
...(await discoverExternalSyllabusSources(apiClient, courseId)),
|
|
58
107
|
});
|
|
59
108
|
}
|
|
60
109
|
throw error;
|
|
@@ -152,6 +201,7 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
152
201
|
if (download) {
|
|
153
202
|
result.download = download;
|
|
154
203
|
}
|
|
204
|
+
Object.assign(result, await discoverExternalSyllabusSources(apiClient, courseId));
|
|
155
205
|
return toolResponse(result);
|
|
156
206
|
}
|
|
157
207
|
catch (error) {
|
|
@@ -8,6 +8,7 @@ import { GetVideoTranscriptSchema } from "./schemas.js";
|
|
|
8
8
|
import { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
9
|
import { cuesToText, paginateText } from "../utils/transcript/captions.js";
|
|
10
10
|
import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId, } from "../utils/transcript/platform.js";
|
|
11
|
+
import { isBrightspaceRelativeLink, readLtiLaunchPage } from "../utils/transcript/lti.js";
|
|
11
12
|
import { getKalturaTranscript } from "../utils/transcript/kaltura.js";
|
|
12
13
|
import { getYouTubeTranscript } from "../utils/transcript/youtube.js";
|
|
13
14
|
import { NoTranscriptError, TranscriptFetchError } from "../utils/transcript/errors.js";
|
|
@@ -28,6 +29,24 @@ async function resolveVideoUrl(apiClient, courseId, topicId) {
|
|
|
28
29
|
}
|
|
29
30
|
return { url: topic.Url };
|
|
30
31
|
}
|
|
32
|
+
/** The quickLink itself, plus one Brightspace page it frames (the tool launch). */
|
|
33
|
+
const MAX_LTI_PAGES = 2;
|
|
34
|
+
/** Request a Brightspace LTI link as the user and read its launch for the video it opens. */
|
|
35
|
+
async function followLtiLaunch(apiClient, path) {
|
|
36
|
+
let next = path;
|
|
37
|
+
for (let page = 0; page < MAX_LTI_PAGES; page++) {
|
|
38
|
+
const html = await apiClient.getPage(next);
|
|
39
|
+
if (html === null)
|
|
40
|
+
return null;
|
|
41
|
+
const finding = readLtiLaunchPage(html);
|
|
42
|
+
if (!finding)
|
|
43
|
+
return null;
|
|
44
|
+
if ("videoUrl" in finding)
|
|
45
|
+
return finding.videoUrl;
|
|
46
|
+
next = finding.nextPath;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
31
50
|
async function fetchTranscript(platform, videoUrl, fetchImpl) {
|
|
32
51
|
switch (platform) {
|
|
33
52
|
case "kaltura": {
|
|
@@ -92,7 +111,7 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
|
|
|
92
111
|
description: "Read the transcript of a video embedded in course content, such as a recorded lecture or explainer clip. " +
|
|
93
112
|
"Call it with courseId and topicId from get_course_content (typeFilter: 'video' or 'other'), or with videoUrl " +
|
|
94
113
|
"directly if you already have the link. Returns transcript text with timestamps, plus title and duration when " +
|
|
95
|
-
"available. Currently supports Kaltura (e.g. BoilerCast) and YouTube; other platforms return a clear message " +
|
|
114
|
+
"available. Currently supports Kaltura (e.g. BoilerCast, including its Brightspace LTI links) and YouTube; other platforms return a clear message " +
|
|
96
115
|
"naming what isn't supported yet. Use offset/maxChars to page through a long transcript. Read only — this " +
|
|
97
116
|
"never marks the video as watched.",
|
|
98
117
|
inputSchema: GetVideoTranscriptSchema,
|
|
@@ -114,6 +133,22 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
|
|
|
114
133
|
else {
|
|
115
134
|
return errorResponse("Provide either videoUrl, or both courseId and topicId to look up the video from course content.");
|
|
116
135
|
}
|
|
136
|
+
if (isBrightspaceRelativeLink(resolvedUrl)) {
|
|
137
|
+
const launchedUrl = await followLtiLaunch(apiClient, resolvedUrl);
|
|
138
|
+
if (!launchedUrl) {
|
|
139
|
+
return toolResponse({
|
|
140
|
+
courseId,
|
|
141
|
+
topicId,
|
|
142
|
+
videoUrl: resolvedUrl,
|
|
143
|
+
platform: "unknown",
|
|
144
|
+
hasTranscript: false,
|
|
145
|
+
message: `This is a Brightspace LTI link (${resolvedUrl}), and its launch did not reveal which video it opens. ` +
|
|
146
|
+
"Some tools only hand over the video after a browser sign-in to the tool itself, which this server " +
|
|
147
|
+
"can't do. The video platform itself may still be supported. Open it in Brightspace directly.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
resolvedUrl = launchedUrl;
|
|
151
|
+
}
|
|
117
152
|
const platform = detectVideoPlatform(resolvedUrl);
|
|
118
153
|
const outcome = await fetchTranscript(platform, resolvedUrl, fetchImpl);
|
|
119
154
|
if ("unsupported" in outcome) {
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Brightspace hands out an LTI tool (BoilerCast's Kaltura KAF, for one) as a
|
|
8
|
+
* relative quickLink such as `/d2l/common/dialogs/quickLink/quickLink.d2l?type=lti&rcode=...`,
|
|
9
|
+
* which names no video at all. Requesting it with the user's session yields
|
|
10
|
+
* the LTI launch page: an auto-submitting form whose action, or one of whose
|
|
11
|
+
* hidden fields, is the tool URL that does name the video. This reads that
|
|
12
|
+
* page; fetching it is the caller's job.
|
|
13
|
+
*/
|
|
14
|
+
import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId } from "./platform.js";
|
|
15
|
+
/** A link into the user's own Brightspace, which only an authenticated request can follow. */
|
|
16
|
+
export function isBrightspaceRelativeLink(url) {
|
|
17
|
+
return url.startsWith("/d2l/");
|
|
18
|
+
}
|
|
19
|
+
function decodeEntities(value) {
|
|
20
|
+
return value
|
|
21
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
22
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/</g, "<")
|
|
25
|
+
.replace(/>/g, ">")
|
|
26
|
+
.replace(/&/g, "&");
|
|
27
|
+
}
|
|
28
|
+
function tagAttributes(html, tag) {
|
|
29
|
+
return [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].map(element => new Map([...element[0].matchAll(/([\w-]+)\s*=\s*(["'])([\s\S]*?)\2/g)].map(match => [
|
|
30
|
+
match[1].toLowerCase(),
|
|
31
|
+
decodeEntities(match[3]),
|
|
32
|
+
])));
|
|
33
|
+
}
|
|
34
|
+
/** True when an adapter could fetch a transcript from `url`, or name the platform it can't yet. */
|
|
35
|
+
function namesVideo(url) {
|
|
36
|
+
switch (detectVideoPlatform(url)) {
|
|
37
|
+
case "unknown":
|
|
38
|
+
return false;
|
|
39
|
+
case "kaltura":
|
|
40
|
+
return extractKalturaIds(url) !== null;
|
|
41
|
+
case "youtube":
|
|
42
|
+
return extractYouTubeVideoId(url) !== null;
|
|
43
|
+
default:
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Kaltura KAF's LTI consumer key is the school's Kaltura partner ID, and a
|
|
49
|
+
* school-branded KAF launch URL often carries only the entry ID, so the key
|
|
50
|
+
* fills in the `wid` the URL left out.
|
|
51
|
+
*/
|
|
52
|
+
function asVideoUrl(candidate, consumerKey) {
|
|
53
|
+
if (!/^https?:\/\//i.test(candidate))
|
|
54
|
+
return null;
|
|
55
|
+
if (namesVideo(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
if (!consumerKey || !/^\d+$/.test(consumerKey))
|
|
58
|
+
return null;
|
|
59
|
+
let withPartner;
|
|
60
|
+
try {
|
|
61
|
+
withPartner = new URL(candidate);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (withPartner.searchParams.has("wid"))
|
|
67
|
+
return null;
|
|
68
|
+
withPartner.searchParams.set("wid", `_${consumerKey}`);
|
|
69
|
+
return namesVideo(withPartner.href) ? withPartner.href : null;
|
|
70
|
+
}
|
|
71
|
+
export function readLtiLaunchPage(html) {
|
|
72
|
+
const inputs = tagAttributes(html, "input");
|
|
73
|
+
const consumerKey = inputs.find(input => input.get("name") === "oauth_consumer_key")?.get("value");
|
|
74
|
+
const candidates = [
|
|
75
|
+
...tagAttributes(html, "form").map(form => form.get("action")),
|
|
76
|
+
...tagAttributes(html, "iframe").map(frame => frame.get("src")),
|
|
77
|
+
...inputs.map(input => input.get("value")),
|
|
78
|
+
].filter((candidate) => Boolean(candidate));
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const videoUrl = asVideoUrl(candidate, consumerKey);
|
|
81
|
+
if (videoUrl)
|
|
82
|
+
return { videoUrl };
|
|
83
|
+
}
|
|
84
|
+
const nextPath = candidates.find(isBrightspaceRelativeLink);
|
|
85
|
+
return nextPath ? { nextPath } : null;
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.15",
|
|
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",
|