brightspace-mcp-server 5.0.12 → 5.0.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.
@@ -70,7 +70,7 @@ export const DownloadFileSchema = z.object({
70
70
  topicId: z.coerce.number().int().positive().optional()
71
71
  .describe("Content topic ID to download (for course content files)."),
72
72
  coursePath: z.string().min(1).max(1024).optional()
73
- .describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf') or the whole quickLink URL. Not combined with topicId, folderId, or newsId."),
73
+ .describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf'), the whole quickLink URL, or a '/content/enforced/<course folder>/...' link exactly as another tool (e.g. get_assignments) returned it. Not combined with topicId, folderId, or newsId."),
74
74
  folderId: z.coerce.number().int().positive().optional()
75
75
  .describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
76
76
  fileId: z.coerce.number().int().positive().optional()
@@ -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 or the quickLink URL a module description carries, and
12
- * refuses anything that could name a file outside the course file store, so
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 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." };
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.0.12",
3
+ "version": "5.0.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",