brightspace-mcp-server 3.10.10 → 3.10.13
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 +1 -1
- package/build/api/client.js +22 -0
- package/build/tools/get-video-transcript.js +36 -1
- package/build/tools/schemas.js +1 -1
- package/build/utils/transcript/lti.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
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
74
|
| People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
|
|
75
75
|
| 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) |
|
|
76
|
+
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
|
|
77
77
|
| Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
|
|
78
78
|
| 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
79
|
|
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();
|
|
@@ -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) {
|
package/build/tools/schemas.js
CHANGED
|
@@ -37,7 +37,7 @@ export const GetMyGradesSchema = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
export const GetAnnouncementsSchema = z.object({
|
|
39
39
|
courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
|
|
40
|
-
count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return"),
|
|
40
|
+
count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return, newest first (1-50, default 10). There is no pagination or offset: when more announcements exist than count, only the newest are returned. To get fuller coverage, call once per course with courseId so courses do not compete for the same slots, and use modifiedSince to return only announcements changed since a cutoff."),
|
|
41
41
|
modifiedSince: z.string().datetime({ offset: true, message: "modifiedSince must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
|
|
42
42
|
.describe("Only return announcements last modified at or after this ISO 8601 datetime (e.g. 2026-01-15T00:00:00Z). Announcements with no modified timestamp are always included. When set, the response reports how many announcements were filtered out."),
|
|
43
43
|
});
|
|
@@ -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.13",
|
|
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",
|