brightspace-mcp-server 5.0.3 → 5.0.5
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
|
@@ -76,7 +76,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
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)) |
|
|
@@ -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);
|
package/build/update.js
CHANGED
|
@@ -50,6 +50,74 @@ export function samePath(a, b) {
|
|
|
50
50
|
};
|
|
51
51
|
return canonical(a) === canonical(b);
|
|
52
52
|
}
|
|
53
|
+
const STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
54
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
55
|
+
function versionTriple(version) {
|
|
56
|
+
const m = STABLE_VERSION.exec(version.trim().replace(/^v/, ""));
|
|
57
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
58
|
+
}
|
|
59
|
+
function compareTriples(a, b) {
|
|
60
|
+
for (let i = 0; i < 3; i++) {
|
|
61
|
+
if (a[i] !== b[i])
|
|
62
|
+
return a[i] - b[i];
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether `nodeVersion` meets an `engines.node` range. Only plain
|
|
68
|
+
* comparator sets (">=20.0.0", ">=20 <23") are understood; anything else
|
|
69
|
+
* yields null so the caller refuses instead of guessing.
|
|
70
|
+
*/
|
|
71
|
+
function satisfiesNodeRange(range, nodeVersion) {
|
|
72
|
+
const have = versionTriple(nodeVersion);
|
|
73
|
+
const comparators = range.trim().split(/\s+/);
|
|
74
|
+
if (!have || comparators.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
for (const comparator of comparators) {
|
|
77
|
+
const m = /^(>=|<=|>|<|=)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(comparator);
|
|
78
|
+
if (!m)
|
|
79
|
+
return null;
|
|
80
|
+
const want = [Number(m[2]), Number(m[3] ?? 0), Number(m[4] ?? 0)];
|
|
81
|
+
const cmp = compareTriples(have, want);
|
|
82
|
+
const ok = { ">=": cmp >= 0, "<=": cmp <= 0, ">": cmp > 0, "<": cmp < 0, "=": cmp === 0 }[m[1] ?? "="];
|
|
83
|
+
if (!ok)
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Decide which commit a checkout should move to, from what npm reports as the
|
|
90
|
+
* `latest` release. Only a stable version, published from a known commit, whose
|
|
91
|
+
* Node.js requirement this runtime meets, is a target; anything else is refused
|
|
92
|
+
* so the update fails closed.
|
|
93
|
+
*/
|
|
94
|
+
export function chooseRelease(registry, nodeVersion) {
|
|
95
|
+
const info = (registry ?? {});
|
|
96
|
+
const { version, gitHead } = info;
|
|
97
|
+
if (typeof version !== "string" || !versionTriple(version)) {
|
|
98
|
+
return { ok: false, reason: "npm did not report a stable release version." };
|
|
99
|
+
}
|
|
100
|
+
if (typeof gitHead !== "string" || !COMMIT_SHA.test(gitHead)) {
|
|
101
|
+
return { ok: false, reason: `npm does not record which commit ${version} was published from.` };
|
|
102
|
+
}
|
|
103
|
+
const nodeRange = info.engines?.node;
|
|
104
|
+
if (nodeRange !== undefined) {
|
|
105
|
+
const satisfied = typeof nodeRange === "string" ? satisfiesNodeRange(nodeRange, nodeVersion) : null;
|
|
106
|
+
if (satisfied === null) {
|
|
107
|
+
return { ok: false, reason: `Cannot check ${version}'s Node.js requirement (${String(nodeRange)}).` };
|
|
108
|
+
}
|
|
109
|
+
if (!satisfied) {
|
|
110
|
+
return { ok: false, reason: `${version} requires Node.js ${nodeRange}; this is Node.js ${nodeVersion}.` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, version, commit: gitHead };
|
|
114
|
+
}
|
|
115
|
+
function fail(...lines) {
|
|
116
|
+
console.error(red(` Error: ${lines[0]}`));
|
|
117
|
+
for (const line of lines.slice(1))
|
|
118
|
+
console.error(` ${line}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
53
121
|
export function main() {
|
|
54
122
|
console.log("");
|
|
55
123
|
console.log(bold("=== Brightspace MCP Server — Update ==="));
|
|
@@ -88,18 +156,44 @@ export function main() {
|
|
|
88
156
|
console.log(dim(" The update will proceed, but your local changes may conflict."));
|
|
89
157
|
console.log("");
|
|
90
158
|
}
|
|
91
|
-
//
|
|
92
|
-
|
|
159
|
+
// Resolve the latest published release: the checkout follows what npm
|
|
160
|
+
// ships, never the unreleased tip of main.
|
|
161
|
+
console.log(dim(" Checking the latest release on npm..."));
|
|
162
|
+
let registry;
|
|
163
|
+
try {
|
|
164
|
+
registry = JSON.parse(run("npm view brightspace-mcp-server@latest version gitHead engines --json", { silent: true }));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
fail("Could not read the latest release from npm.", "Check your network connection and try again.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const release = chooseRelease(registry, process.version);
|
|
171
|
+
if (!release.ok) {
|
|
172
|
+
fail("No release this checkout can safely update to.", release.reason);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const { version: targetVersion, commit } = release;
|
|
176
|
+
// Fetch, then confirm the published commit really is that release on main.
|
|
177
|
+
console.log(dim(` Fetching release ${targetVersion}...`));
|
|
93
178
|
try {
|
|
94
179
|
run("git fetch origin main", { silent: true });
|
|
95
180
|
}
|
|
96
181
|
catch {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
182
|
+
fail("Failed to fetch from remote.", "Check your network connection and try again.");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
run(`git merge-base --is-ancestor ${commit} origin/main`, { silent: true });
|
|
187
|
+
const released = JSON.parse(run(`git show ${commit}:package.json`, { silent: true }));
|
|
188
|
+
if (released.version !== targetVersion)
|
|
189
|
+
throw new Error("version mismatch");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
fail(`Could not verify release ${targetVersion} (commit ${commit.slice(0, 7)}) on origin/main.`, "Nothing was changed.");
|
|
193
|
+
return;
|
|
100
194
|
}
|
|
101
195
|
// Check if we're behind
|
|
102
|
-
const behindCount = run(
|
|
196
|
+
const behindCount = run(`git rev-list --count HEAD..${commit}`, { silent: true });
|
|
103
197
|
if (behindCount === "0") {
|
|
104
198
|
console.log("");
|
|
105
199
|
console.log(green(" Already up to date!"));
|
|
@@ -108,22 +202,21 @@ export function main() {
|
|
|
108
202
|
}
|
|
109
203
|
// Show what's new
|
|
110
204
|
console.log("");
|
|
111
|
-
console.log(bold(` ${behindCount} new commit${behindCount === "1" ? "" : "s"}:`));
|
|
205
|
+
console.log(bold(` ${behindCount} new commit${behindCount === "1" ? "" : "s"} in ${targetVersion}:`));
|
|
112
206
|
console.log("");
|
|
113
|
-
const log = run(
|
|
207
|
+
const log = run(`git log HEAD..${commit} --oneline`, { silent: true });
|
|
114
208
|
for (const line of log.split("\n")) {
|
|
115
209
|
console.log(` ${dim("•")} ${line}`);
|
|
116
210
|
}
|
|
117
211
|
console.log("");
|
|
118
|
-
//
|
|
119
|
-
console.log(dim(
|
|
212
|
+
// Move to the release
|
|
213
|
+
console.log(dim(` Updating to ${targetVersion}...`));
|
|
120
214
|
try {
|
|
121
|
-
run(
|
|
215
|
+
run(`git merge --ff-only ${commit}`, { silent: true });
|
|
122
216
|
}
|
|
123
217
|
catch {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
process.exit(1);
|
|
218
|
+
fail(`Failed to update to ${targetVersion}.`, "Your checkout has local commits or changes in the way. Resolve them and try again.");
|
|
219
|
+
return;
|
|
127
220
|
}
|
|
128
221
|
// Install dependencies
|
|
129
222
|
console.log(dim(" Installing dependencies..."));
|
|
@@ -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.5",
|
|
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",
|