brightspace-mcp-server 3.15.1 → 3.15.3
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 -2
- package/build/api/client.js +16 -2
- package/build/setup.js +26 -47
- package/build/tools/download-file.js +11 -3
- package/build/tools/get-course-content.js +1 -1
- package/build/utils/course-files.js +18 -14
- package/build/utils/html-attributes.js +26 -0
- package/build/utils/transcript/lti.js +1 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
|
|
|
57
57
|
| McGill University | Microsoft Entra | `--mcgill` |
|
|
58
58
|
| Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
|
|
59
59
|
| Ngee Ann Polytechnic | Microsoft Entra | `--ngeeann` |
|
|
60
|
-
| Any other D2L school | Paste your Brightspace address
|
|
60
|
+
| Any other D2L school | Paste your Brightspace address. If the login page isn't recognized, pick option 2 at setup's sign-in question so the page opens for you to finish by hand | none |
|
|
61
61
|
|
|
62
62
|
School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
63
63
|
|
|
@@ -97,7 +97,6 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
|
|
|
97
97
|
| Setting | What it does |
|
|
98
98
|
|---------|--------------|
|
|
99
99
|
| `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this as option 2 of its one "how often do you want to approve sign-ins" question and saves it as `rememberMfa` in `config.json`; the env var overrides it ([details](docs/sign-in.md#fewer-2fa-prompts)) |
|
|
100
|
-
| `D2L_PASSWORDLESS=true` | Sign in with Microsoft Entra's passwordless phone approval, so no password is saved on the computer (off by default; Entra schools only). Every sign-in, including automatic ones, then waits for you to approve on your phone, so it suits interactive use, not unattended or scheduled jobs. `setup` asks this and saves it as `passwordless` in `config.json`; the env var overrides it ([details](docs/sign-in.md#passwordless-sign-in)) |
|
|
101
100
|
| `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup` (option 3), which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
|
|
102
101
|
| `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
|
|
103
102
|
| `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
|
package/build/api/client.js
CHANGED
|
@@ -12,6 +12,20 @@ import { withRetry, isRetryableFailure, retryAfterMsFrom, parseRetryAfter, } fro
|
|
|
12
12
|
import { log } from "../utils/logger.js";
|
|
13
13
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
14
14
|
import { devActivity } from "../utils/dev-activity.js";
|
|
15
|
+
/**
|
|
16
|
+
* True when a followed redirect ended on this Brightspace's sign-in page. A
|
|
17
|
+
* web route asked with a dead cookie 302s there rather than serving the
|
|
18
|
+
* expired-session stub, and fetch follows the redirect to an HTTP 200.
|
|
19
|
+
*/
|
|
20
|
+
function landedOnLogin(responseUrl, baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(responseUrl);
|
|
23
|
+
return url.origin === new URL(baseUrl).origin && url.pathname.toLowerCase() === "/d2l/login";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
15
29
|
/** An ordinary course HTML link to the login page is not an expired session. */
|
|
16
30
|
function isExpiredSessionRedirect(body, baseUrl) {
|
|
17
31
|
const expiredTarget = (target) => {
|
|
@@ -575,8 +589,8 @@ export class D2LApiClient {
|
|
|
575
589
|
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
576
590
|
if (contentType.startsWith("text/html")) {
|
|
577
591
|
const body = await response.text();
|
|
578
|
-
if (isExpiredSessionRedirect(body, this.baseUrl)) {
|
|
579
|
-
log("DEBUG", "File download answered with the
|
|
592
|
+
if (isExpiredSessionRedirect(body, this.baseUrl) || landedOnLogin(response.url, this.baseUrl)) {
|
|
593
|
+
log("DEBUG", "File download answered with the sign-in page, treating it as a 401");
|
|
580
594
|
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
581
595
|
}
|
|
582
596
|
// A legitimate HTML page: hand back an equivalent response with the
|
package/build/setup.js
CHANGED
|
@@ -361,7 +361,7 @@ export function buildConfigToSave(existing, answers) {
|
|
|
361
361
|
username: answers.username,
|
|
362
362
|
// Always the freshly typed one: a carried v1 plaintext password would
|
|
363
363
|
// otherwise be the value written to the native store.
|
|
364
|
-
password: answers.
|
|
364
|
+
password: answers.password,
|
|
365
365
|
// Only ever the freshly typed one. Never read back out of `carried`: the
|
|
366
366
|
// enrollment lives in the credential store, never in config.json, so a
|
|
367
367
|
// value here could only have come from this run.
|
|
@@ -370,8 +370,10 @@ export function buildConfigToSave(existing, answers) {
|
|
|
370
370
|
};
|
|
371
371
|
if (answers.rememberMfa !== undefined)
|
|
372
372
|
config.rememberMfa = answers.rememberMfa;
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
// Setup no longer offers passwordless sign-in and always saves a password,
|
|
374
|
+
// so a carried `passwordless: true` would make sign-in ignore that password.
|
|
375
|
+
// D2L_PASSWORDLESS still turns it on for anyone who wants it.
|
|
376
|
+
delete config.passwordless;
|
|
375
377
|
if (answers.campus)
|
|
376
378
|
config.campus = answers.campus;
|
|
377
379
|
return config;
|
|
@@ -438,6 +440,10 @@ async function main() {
|
|
|
438
440
|
}
|
|
439
441
|
console.log(dim(" By Rohan Muppa — github.com/rohanmuppa/brightspace-mcp-server"));
|
|
440
442
|
console.log("");
|
|
443
|
+
// An assistant often opens this wizard in a side panel the user has never
|
|
444
|
+
// used, and their typing keeps going to the chat box until they click here.
|
|
445
|
+
console.log(" Click in this window, then type your answer to each question and press Enter.");
|
|
446
|
+
console.log("");
|
|
441
447
|
const rl = readline.createInterface({
|
|
442
448
|
input: process.stdin,
|
|
443
449
|
output: process.stdout,
|
|
@@ -496,44 +502,20 @@ async function main() {
|
|
|
496
502
|
}
|
|
497
503
|
console.log("");
|
|
498
504
|
// ── Step 3: Password (hidden) ────────────────────────────────────
|
|
499
|
-
// Opt-in only, asked the way remember-MFA is: passwordless trades a saved
|
|
500
|
-
// password for a phone approval on every sign-in, so it is never the default.
|
|
501
|
-
let savedPasswordless;
|
|
502
|
-
try {
|
|
503
|
-
savedPasswordless = configStoreExists() ? loadConfigStore().passwordless : undefined;
|
|
504
|
-
}
|
|
505
|
-
catch {
|
|
506
|
-
// An invalid old config is replaced by the setup values below.
|
|
507
|
-
}
|
|
508
|
-
console.log(dim(" Microsoft Entra schools can sign you in with only a phone approval, so no password is saved on this computer."));
|
|
509
|
-
console.log(dim(" The cost: every sign-in, including automatic ones after a session expires, waits until you approve on your phone."));
|
|
510
|
-
console.log(dim(" Fine for using an assistant at your desk; not for unattended or scheduled use. It needs passwordless phone sign-in"));
|
|
511
|
-
console.log(dim(" turned on in Microsoft Authenticator first, or Microsoft will still ask for a password and sign-in will stop."));
|
|
512
|
-
const defaultPasswordless = savedPasswordless === true ? "yes" : "no";
|
|
513
|
-
let passwordlessAnswer = "";
|
|
514
|
-
while (!/^(y(es)?|no?)$/i.test(passwordlessAnswer)) {
|
|
515
|
-
passwordlessAnswer = await ask(rl, ` Sign in without a saved password (Microsoft Entra only)? (yes/no) [${defaultPasswordless}]: `) || defaultPasswordless;
|
|
516
|
-
if (!/^(y(es)?|no?)$/i.test(passwordlessAnswer))
|
|
517
|
-
console.log(yellow(" Please enter yes or no."));
|
|
518
|
-
}
|
|
519
|
-
const passwordless = /^y/i.test(passwordlessAnswer);
|
|
520
|
-
console.log("");
|
|
521
505
|
// Close the rl temporarily since askPassword manages its own
|
|
522
506
|
rl.close();
|
|
523
507
|
let password = "";
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
console.log(yellow(" Password is required."));
|
|
533
|
-
}
|
|
508
|
+
console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
|
|
509
|
+
const passwordPrompt = preset
|
|
510
|
+
? `What is your ${preset.name} password? `
|
|
511
|
+
: "What is your Brightspace password? ";
|
|
512
|
+
while (!password) {
|
|
513
|
+
password = await askPassword(passwordPrompt);
|
|
514
|
+
if (!password) {
|
|
515
|
+
console.log(yellow(" Password is required."));
|
|
534
516
|
}
|
|
535
|
-
console.log("");
|
|
536
517
|
}
|
|
518
|
+
console.log("");
|
|
537
519
|
// Re-open readline for remaining prompts
|
|
538
520
|
let rl2 = readline.createInterface({
|
|
539
521
|
input: process.stdin,
|
|
@@ -553,9 +535,9 @@ async function main() {
|
|
|
553
535
|
// MFA you have is detected at sign-in time, so offering "approve a prompt"
|
|
554
536
|
// and "type a code" as separate choices would be a distinction the code does
|
|
555
537
|
// not make, and picking between them would change nothing on disk.
|
|
556
|
-
console.log("
|
|
557
|
-
console.log(" 1.
|
|
558
|
-
console.log(" 2.
|
|
538
|
+
console.log(" How do you confirm it's you when you sign in?");
|
|
539
|
+
console.log(" 1. With a phone app: I tap Approve or type a code (recommended)");
|
|
540
|
+
console.log(" 2. Another way, like a text message: show me the sign-in page");
|
|
559
541
|
let savedHeadless;
|
|
560
542
|
let savedRememberMfa;
|
|
561
543
|
try {
|
|
@@ -575,8 +557,8 @@ async function main() {
|
|
|
575
557
|
}
|
|
576
558
|
const headless = mfaChoice !== "2";
|
|
577
559
|
console.log(dim(headless
|
|
578
|
-
? "
|
|
579
|
-
: "
|
|
560
|
+
? " Sign-in will run in the background."
|
|
561
|
+
: " The sign-in page will open when you sign in here, so you can finish it yourself."));
|
|
580
562
|
console.log("");
|
|
581
563
|
// ── Step 4b: How often to approve (the one 2FA question) ─────────
|
|
582
564
|
// Opt-in only: the default asks every time, and a shared computer must never
|
|
@@ -621,13 +603,10 @@ async function main() {
|
|
|
621
603
|
totpUri,
|
|
622
604
|
headless,
|
|
623
605
|
rememberMfa,
|
|
624
|
-
passwordless,
|
|
625
606
|
campus: campus || undefined,
|
|
626
607
|
});
|
|
627
608
|
await saveSecureConfig(config);
|
|
628
|
-
console.log(green(
|
|
629
|
-
? " No password saved: each sign-in will wait for your phone approval."
|
|
630
|
-
: " Password saved in your operating system credential store."));
|
|
609
|
+
console.log(green(" Password saved in your operating system credential store."));
|
|
631
610
|
if (totpUri)
|
|
632
611
|
console.log(green(" Authenticator enrollment saved in your operating system credential store."));
|
|
633
612
|
if (shortcuts?.removeKey) {
|
|
@@ -664,7 +643,7 @@ async function main() {
|
|
|
664
643
|
// ── Step 7: Claude Desktop auto-config ───────────────────────────
|
|
665
644
|
const claudePath = getClaudeDesktopConfigPath();
|
|
666
645
|
if (claudePath) {
|
|
667
|
-
const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? (yes/no): ");
|
|
646
|
+
const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? Highly recommended. (yes/no): ");
|
|
668
647
|
if (/^y(es)?$/i.test(configClaude) &&
|
|
669
648
|
await registerWithClient(rl2, "Claude Desktop", inspectMcpClient(claudePath), () => configureMcpClient(claudePath))) {
|
|
670
649
|
configuredClients.push("Claude Desktop");
|
|
@@ -696,7 +675,7 @@ async function main() {
|
|
|
696
675
|
for (const client of cliMcpClients()) {
|
|
697
676
|
if (!isCliAvailable(client))
|
|
698
677
|
continue;
|
|
699
|
-
const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? (yes/no): `);
|
|
678
|
+
const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? Highly recommended. (yes/no): `);
|
|
700
679
|
if (/^y(es)?$/i.test(configureClient) &&
|
|
701
680
|
await registerWithClient(rl2, client.displayName, inspectCliMcpClient(client), (replace) => registerCliMcpClient(client, { replace }))) {
|
|
702
681
|
configuredClients.push(client.displayName);
|
|
@@ -288,7 +288,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
288
288
|
}
|
|
289
289
|
// Determine download source
|
|
290
290
|
if (coursePath !== undefined) {
|
|
291
|
-
const filePath = parseCoursePath(coursePath, courseId);
|
|
291
|
+
const filePath = parseCoursePath(coursePath, courseId, apiClient.origin);
|
|
292
292
|
if (typeof filePath !== "string")
|
|
293
293
|
return errorResponse(filePath.error);
|
|
294
294
|
return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
|
|
@@ -311,7 +311,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
311
311
|
return await downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename);
|
|
312
312
|
}
|
|
313
313
|
else {
|
|
314
|
-
return errorResponse("Either topicId (for content files), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
|
|
314
|
+
return errorResponse("Either topicId (for content files), coursePath (for course files linked from a module description), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
catch (error) {
|
|
@@ -329,7 +329,15 @@ async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, c
|
|
|
329
329
|
log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
|
|
330
330
|
const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
|
|
331
331
|
if (!response) {
|
|
332
|
-
return errorResponse(`Course files
|
|
332
|
+
return errorResponse(`Course files are served only to a signed-in Brightspace web session, and there is no valid one: the session cookie is missing or has expired. Sign in again with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`);
|
|
333
|
+
}
|
|
334
|
+
// A missing file or a refused one can come back as an ordinary Brightspace
|
|
335
|
+
// web page with HTTP 200. That page is not the file, so it is never saved
|
|
336
|
+
// or shown under the file's name.
|
|
337
|
+
const contentType = (response.headers.get("Content-Type") ?? "").toLowerCase();
|
|
338
|
+
if (contentType.startsWith("text/html") && !/\.html?$/i.test(filePath)) {
|
|
339
|
+
await response.body?.cancel();
|
|
340
|
+
return errorResponse(`Brightspace answered with a web page instead of the course file "${filePath}". The file may have been moved or deleted, or this account may not be allowed to open it.`);
|
|
333
341
|
}
|
|
334
342
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
335
343
|
const filename = parseContentDispositionFilename(disposition) ?? filePath.split("/").pop() ?? "download";
|
|
@@ -109,9 +109,9 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
109
109
|
else if (currentDepth >= MAX_CONTENT_DEPTH) {
|
|
110
110
|
log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
|
|
111
111
|
}
|
|
112
|
-
const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId) : [];
|
|
113
112
|
// Only include module if it has matching children (or filter is 'all')
|
|
114
113
|
if (typeFilter === 'all' || processedChildren.length > 0) {
|
|
114
|
+
const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId, apiClient.origin) : [];
|
|
115
115
|
tree.push({
|
|
116
116
|
type: 'module',
|
|
117
117
|
id: item.Id,
|
|
@@ -3,25 +3,26 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
+
import { tagAttributes } from "./html-attributes.js";
|
|
7
|
+
import { toBrightspacePath } from "./transcript/lti.js";
|
|
6
8
|
export const QUICKLINK_PATH = "/d2l/common/dialogs/quickLink/quickLink.d2l";
|
|
7
9
|
/**
|
|
8
10
|
* Reduce a coursePath argument to a plain course-file path ("Notes/a.pdf").
|
|
9
11
|
* Accepts that path or the quickLink URL a module description carries, and
|
|
10
12
|
* refuses anything that could name a file outside the course file store, so
|
|
11
13
|
* the argument can only ever steer the request inside this course's files.
|
|
14
|
+
* An absolute quickLink URL must be on `origin`; without one, only a
|
|
15
|
+
* root-relative link is accepted.
|
|
12
16
|
*/
|
|
13
|
-
export function parseCoursePath(input, courseId) {
|
|
17
|
+
export function parseCoursePath(input, courseId, origin) {
|
|
14
18
|
let filePath = input.trim();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return { error: "coursePath
|
|
22
|
-
}
|
|
23
|
-
if (link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
|
|
24
|
-
return { error: "coursePath must be a course file path or a quickLink URL with type=coursefile." };
|
|
19
|
+
// A plain course-file path never starts with "/" or names a scheme, so
|
|
20
|
+
// anything that does is a link, however its file name reads.
|
|
21
|
+
if (filePath.startsWith("/") || /^[a-z][a-z\d+.-]*:\/\//i.test(filePath)) {
|
|
22
|
+
const brightspacePath = toBrightspacePath(filePath, origin);
|
|
23
|
+
const link = brightspacePath === null ? null : new URL(brightspacePath, "https://placeholder.invalid");
|
|
24
|
+
if (link === null || link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
|
|
25
|
+
return { error: "coursePath must be a course file path or a quickLink URL on this Brightspace with type=coursefile." };
|
|
25
26
|
}
|
|
26
27
|
const ou = link.searchParams.get("ou");
|
|
27
28
|
if (ou !== null && ou !== String(courseId)) {
|
|
@@ -45,10 +46,13 @@ export function parseCoursePath(input, courseId) {
|
|
|
45
46
|
* HTML because the Markdown conversion escapes parentheses in file names.
|
|
46
47
|
* Anything parseCoursePath would refuse, and links to other courses, are left out.
|
|
47
48
|
*/
|
|
48
|
-
export function extractCourseFilePaths(html, courseId) {
|
|
49
|
+
export function extractCourseFilePaths(html, courseId, origin) {
|
|
49
50
|
const found = [];
|
|
50
|
-
for (const
|
|
51
|
-
const
|
|
51
|
+
for (const anchor of tagAttributes(html, "a")) {
|
|
52
|
+
const href = anchor.get("href");
|
|
53
|
+
if (!href?.includes("quickLink.d2l"))
|
|
54
|
+
continue;
|
|
55
|
+
const parsed = parseCoursePath(href, courseId, origin);
|
|
52
56
|
if (typeof parsed === "string" && !found.includes(parsed))
|
|
53
57
|
found.push(parsed);
|
|
54
58
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
/** Decode the character references Brightspace's HTML editor writes into attribute values. */
|
|
7
|
+
export function decodeEntities(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
10
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/&/g, "&");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The attributes of every `<tag>` element in `html`, names lowercased and
|
|
18
|
+
* values entity-decoded. A value ends only at its own closing quote, so an
|
|
19
|
+
* apostrophe inside a double-quoted href stays part of it.
|
|
20
|
+
*/
|
|
21
|
+
export function tagAttributes(html, tag) {
|
|
22
|
+
return [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].map(element => new Map([...element[0].matchAll(/([\w-]+)\s*=\s*(["'])([\s\S]*?)\2/g)].map(match => [
|
|
23
|
+
match[1].toLowerCase(),
|
|
24
|
+
decodeEntities(match[3]),
|
|
25
|
+
])));
|
|
26
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* page; fetching it is the caller's job.
|
|
13
13
|
*/
|
|
14
14
|
import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId } from "./platform.js";
|
|
15
|
+
import { tagAttributes } from "../html-attributes.js";
|
|
15
16
|
/** A link into the user's own Brightspace, which only an authenticated request can follow. */
|
|
16
17
|
export function isBrightspaceRelativeLink(url) {
|
|
17
18
|
return url.startsWith("/d2l/");
|
|
@@ -50,21 +51,6 @@ export function toBrightspacePath(url, origin) {
|
|
|
50
51
|
return null;
|
|
51
52
|
return `${parsed.pathname}${parsed.search}`;
|
|
52
53
|
}
|
|
53
|
-
function decodeEntities(value) {
|
|
54
|
-
return value
|
|
55
|
-
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
56
|
-
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
57
|
-
.replace(/"/g, '"')
|
|
58
|
-
.replace(/</g, "<")
|
|
59
|
-
.replace(/>/g, ">")
|
|
60
|
-
.replace(/&/g, "&");
|
|
61
|
-
}
|
|
62
|
-
function tagAttributes(html, tag) {
|
|
63
|
-
return [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].map(element => new Map([...element[0].matchAll(/([\w-]+)\s*=\s*(["'])([\s\S]*?)\2/g)].map(match => [
|
|
64
|
-
match[1].toLowerCase(),
|
|
65
|
-
decodeEntities(match[3]),
|
|
66
|
-
])));
|
|
67
|
-
}
|
|
68
54
|
/** True when an adapter could fetch a transcript from `url`, or name the platform it can't yet. */
|
|
69
55
|
function namesVideo(url) {
|
|
70
56
|
switch (detectVideoPlatform(url)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.15.
|
|
3
|
+
"version": "3.15.3",
|
|
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",
|