brightspace-mcp-server 5.0.12 → 5.1.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 +11 -3
- package/build/http-server.js +3 -1
- package/build/index.js +68 -44
- package/build/runtime-relay.js +290 -0
- package/build/server.js +5 -6
- package/build/shared-runtime.js +126 -0
- package/build/tools/download-dropbox-submission-file.js +10 -10
- package/build/tools/download-file.js +15 -15
- package/build/tools/get-calendar-events.js +1 -1
- package/build/tools/get-discussions.js +1 -1
- package/build/tools/get-dropbox-feedback.js +2 -2
- package/build/tools/get-dropbox-folders.js +1 -1
- package/build/tools/get-dropbox-submissions.js +1 -1
- package/build/tools/get-dropbox-user-submissions.js +1 -1
- package/build/tools/get-my-grades.js +1 -1
- package/build/tools/get-rubrics-for-object.js +1 -1
- package/build/tools/get-syllabus.js +5 -5
- package/build/tools/get-video-transcript.js +14 -2
- package/build/tools/schemas.js +1 -1
- package/build/tools/tool-helpers.js +8 -2
- package/build/utils/config.js +8 -3
- package/build/utils/course-files.js +68 -12
- package/build/utils/dev-activity.js +13 -2
- package/build/utils/error-kind.js +47 -0
- package/build/utils/startup-phase.js +37 -0
- package/package.json +1 -1
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
import { tagAttributes } from "./html-attributes.js";
|
|
7
7
|
import { toBrightspacePath } from "./transcript/lti.js";
|
|
8
8
|
export const QUICKLINK_PATH = "/d2l/common/dialogs/quickLink/quickLink.d2l";
|
|
9
|
+
const ENFORCED_PREFIX = "/content/enforced/";
|
|
9
10
|
/**
|
|
10
11
|
* Reduce a coursePath argument to a plain course-file path ("Notes/a.pdf").
|
|
11
|
-
* Accepts that path
|
|
12
|
-
*
|
|
12
|
+
* Accepts that path, the quickLink URL a module description carries, or the
|
|
13
|
+
* `/content/enforced/<course folder>/...` href an assignment's instructions
|
|
14
|
+
* carry, and refuses anything that could name a file outside the course file store, so
|
|
13
15
|
* the argument can only ever steer the request inside this course's files.
|
|
14
16
|
* An absolute quickLink URL must be on `origin`; without one, only a
|
|
15
17
|
* root-relative link is accepted.
|
|
@@ -19,16 +21,10 @@ export function parseCoursePath(input, courseId, origin) {
|
|
|
19
21
|
// A plain course-file path never starts with "/" or names a scheme, so
|
|
20
22
|
// anything that does is a link, however its file name reads.
|
|
21
23
|
if (filePath.startsWith("/") || /^[a-z][a-z\d+.-]*:\/\//i.test(filePath)) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
const ou = link.searchParams.get("ou");
|
|
28
|
-
if (ou !== null && ou !== String(courseId)) {
|
|
29
|
-
return { error: `This quickLink belongs to course ${ou}, not course ${courseId}.` };
|
|
30
|
-
}
|
|
31
|
-
filePath = link.searchParams.get("fileId") ?? "";
|
|
24
|
+
const linked = enforcedFilePath(filePath, courseId, origin) ?? quickLinkFilePath(filePath, courseId, origin);
|
|
25
|
+
if (typeof linked !== "string")
|
|
26
|
+
return linked;
|
|
27
|
+
filePath = linked;
|
|
32
28
|
}
|
|
33
29
|
const segments = filePath.split("/");
|
|
34
30
|
if (filePath === "" ||
|
|
@@ -40,6 +36,66 @@ export function parseCoursePath(input, courseId, origin) {
|
|
|
40
36
|
}
|
|
41
37
|
return filePath;
|
|
42
38
|
}
|
|
39
|
+
/** The course-file path a `quickLink.d2l?type=coursefile` link names, or why it can't be used. */
|
|
40
|
+
function quickLinkFilePath(input, courseId, origin) {
|
|
41
|
+
const brightspacePath = toBrightspacePath(input, origin);
|
|
42
|
+
const link = brightspacePath === null ? null : new URL(brightspacePath, "https://placeholder.invalid");
|
|
43
|
+
if (link === null || link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
|
|
44
|
+
return { error: "coursePath must be a course file path, a /content/enforced/ link, or a quickLink URL on this Brightspace with type=coursefile." };
|
|
45
|
+
}
|
|
46
|
+
const ou = link.searchParams.get("ou");
|
|
47
|
+
if (ou !== null && ou !== String(courseId)) {
|
|
48
|
+
return { error: `This quickLink belongs to course ${ou}, not course ${courseId}.` };
|
|
49
|
+
}
|
|
50
|
+
return link.searchParams.get("fileId") ?? "";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The course-file path a `/content/enforced/<course folder>/<path>` href
|
|
54
|
+
* names, an error when it names one unsafely, or null when the input is not
|
|
55
|
+
* such an href. Read from the raw text, not a parsed URL, because URL parsing
|
|
56
|
+
* would resolve a `..` (or `%2e%2e`) segment out of the course folder before
|
|
57
|
+
* it could be refused. A folder named `<ou>-...` must belong to this course.
|
|
58
|
+
*/
|
|
59
|
+
function enforcedFilePath(input, courseId, origin) {
|
|
60
|
+
let path = input;
|
|
61
|
+
if (!path.startsWith("/")) {
|
|
62
|
+
let link;
|
|
63
|
+
try {
|
|
64
|
+
link = new URL(path);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!origin || link.origin !== new URL(origin).origin || link.username || link.password)
|
|
70
|
+
return null;
|
|
71
|
+
path = path.slice(path.indexOf("/", path.indexOf("//") + 2));
|
|
72
|
+
}
|
|
73
|
+
if (!path.startsWith(ENFORCED_PREFIX))
|
|
74
|
+
return null;
|
|
75
|
+
// Brightspace serves a backslash in a course-file path as a folder separator
|
|
76
|
+
// (old imported courses link files as csfiles\home_dir\...), so it is read
|
|
77
|
+
// as one here, before the segments are checked.
|
|
78
|
+
const [folder, ...rest] = path.slice(ENFORCED_PREFIX.length).split(/[?#]/)[0].replace(/\\/g, "/").split("/");
|
|
79
|
+
const ou = /^(\d+)-/.exec(folder)?.[1];
|
|
80
|
+
if (ou !== undefined && ou !== String(courseId)) {
|
|
81
|
+
return { error: `This file belongs to course ${ou}, not course ${courseId}.` };
|
|
82
|
+
}
|
|
83
|
+
const segments = [];
|
|
84
|
+
for (const segment of rest) {
|
|
85
|
+
let decoded;
|
|
86
|
+
try {
|
|
87
|
+
decoded = decodeURIComponent(segment);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { error: "coursePath is not a valid link: it has malformed percent-encoding." };
|
|
91
|
+
}
|
|
92
|
+
// An encoded "/" would let one segment smuggle in several, ".." among them.
|
|
93
|
+
if (decoded.includes("/"))
|
|
94
|
+
return { error: "coursePath must not hide a '/' inside a single path segment." };
|
|
95
|
+
segments.push(decoded);
|
|
96
|
+
}
|
|
97
|
+
return segments.join("/");
|
|
98
|
+
}
|
|
43
99
|
/**
|
|
44
100
|
* The course file paths a module description links through
|
|
45
101
|
* `quickLink.d2l?type=coursefile`, in page order, once each. Read from the raw
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { AUTH_PHASES } from "../auth/auth-phases.js";
|
|
6
|
+
import { ERROR_KINDS, errorKind, resultErrorKind } from "./error-kind.js";
|
|
6
7
|
const calls = new AsyncLocalStorage();
|
|
7
8
|
const runId = randomUUID();
|
|
8
9
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
@@ -42,6 +43,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
42
43
|
record.reason = fields.reason;
|
|
43
44
|
if (AUTH_PHASES.includes(fields.phase ?? ""))
|
|
44
45
|
record.phase = fields.phase;
|
|
46
|
+
if (ERROR_KINDS.includes(fields.errorKind ?? ""))
|
|
47
|
+
record.errorKind = fields.errorKind;
|
|
45
48
|
try {
|
|
46
49
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
47
50
|
const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
|
|
@@ -77,16 +80,24 @@ export function trackToolActivity(server) {
|
|
|
77
80
|
lastStart = started;
|
|
78
81
|
return calls.run(context, async () => {
|
|
79
82
|
let outcome = "error";
|
|
83
|
+
let kind;
|
|
80
84
|
try {
|
|
81
85
|
const result = await callback(...args);
|
|
82
|
-
if (
|
|
86
|
+
if (result?.isError) {
|
|
87
|
+
kind = resultErrorKind(result);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
83
90
|
outcome = "success";
|
|
84
91
|
lastSuccess = Date.now();
|
|
85
92
|
}
|
|
86
93
|
return result;
|
|
87
94
|
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
kind = errorKind(error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
88
99
|
finally {
|
|
89
|
-
write("tool_finished", { outcome, elapsedMs: Date.now() - started });
|
|
100
|
+
write("tool_finished", { outcome, errorKind: kind, elapsedMs: Date.now() - started });
|
|
90
101
|
}
|
|
91
102
|
});
|
|
92
103
|
})));
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
* A diagnostic category for a failed tool call, chosen from a closed set this
|
|
7
|
+
* package controls. Derived from the error's type and status only, never from
|
|
8
|
+
* its message, so the dev activity log can say why a call failed without
|
|
9
|
+
* recording paths, URLs, arguments, or content.
|
|
10
|
+
*/
|
|
11
|
+
import { ZodError } from "zod";
|
|
12
|
+
import { ApiError, NetworkError } from "../api/errors.js";
|
|
13
|
+
import { AuthProcessError } from "../auth/auth-runner.js";
|
|
14
|
+
import { DownloadError } from "./download-errors.js";
|
|
15
|
+
export const ERROR_KINDS = ["invalidInput", "destination", "notFound", "permission", "auth", "rateLimited", "http",
|
|
16
|
+
"transport", "providerBlocked", "unsupportedFormat", "rejectedFile", "unknown"];
|
|
17
|
+
const API_STATUS_KINDS = {
|
|
18
|
+
401: "auth", 403: "permission", 404: "notFound", 429: "rateLimited",
|
|
19
|
+
};
|
|
20
|
+
export function errorKind(error) {
|
|
21
|
+
if (error instanceof ZodError)
|
|
22
|
+
return "invalidInput";
|
|
23
|
+
if (error instanceof AuthProcessError)
|
|
24
|
+
return "auth";
|
|
25
|
+
if (error instanceof ApiError)
|
|
26
|
+
return API_STATUS_KINDS[error.status] ?? "http";
|
|
27
|
+
if (error instanceof NetworkError)
|
|
28
|
+
return "transport";
|
|
29
|
+
if (error instanceof DownloadError) {
|
|
30
|
+
return error.kind === "unsupportedType" || error.kind === "undetectableType" ? "unsupportedFormat" : "rejectedFile";
|
|
31
|
+
}
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
const resultKinds = new WeakMap();
|
|
35
|
+
/** Remembers which error a returned tool result describes. */
|
|
36
|
+
export function withErrorKind(result, error) {
|
|
37
|
+
return tagErrorKind(result, errorKind(error));
|
|
38
|
+
}
|
|
39
|
+
/** Records the category of an error result a tool built itself. */
|
|
40
|
+
export function tagErrorKind(result, kind) {
|
|
41
|
+
resultKinds.set(result, kind);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
/** The category of a returned error result; "unknown" when no error was attached. */
|
|
45
|
+
export function resultErrorKind(result) {
|
|
46
|
+
return (typeof result === "object" && result !== null && resultKinds.get(result)) || "unknown";
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
import { log } from "./logger.js";
|
|
7
|
+
/** How long a phase may run before one "still waiting" warning is logged. */
|
|
8
|
+
const SLOW_PHASE_MS = 10_000;
|
|
9
|
+
/**
|
|
10
|
+
* Run one startup step with timestamped stderr lines around it: one before
|
|
11
|
+
* the work starts, one with its duration when it ends, and a single warning
|
|
12
|
+
* if it is still running after SLOW_PHASE_MS. A client whose initialize
|
|
13
|
+
* request times out can then see which step the server was stuck in.
|
|
14
|
+
*
|
|
15
|
+
* The phase name is printed verbatim, so it must never carry a username,
|
|
16
|
+
* URL, or any other account detail.
|
|
17
|
+
*/
|
|
18
|
+
export async function startupPhase(name, work) {
|
|
19
|
+
log("INFO", `Startup: ${name}...`);
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
const slow = setTimeout(() => {
|
|
22
|
+
log("WARN", `Startup: still ${name} after ${SLOW_PHASE_MS / 1000} s`);
|
|
23
|
+
}, SLOW_PHASE_MS);
|
|
24
|
+
slow.unref?.();
|
|
25
|
+
try {
|
|
26
|
+
const result = await work();
|
|
27
|
+
log("INFO", `Startup: ${name} done in ${Date.now() - started} ms`);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
log("WARN", `Startup: ${name} failed after ${Date.now() - started} ms`);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
clearTimeout(slow);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.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",
|