brightspace-mcp-server 5.1.44 → 5.2.1
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 +102 -87
- package/build/api/client.js +32 -13
- package/build/index.js +6 -2
- package/build/runtime-relay.js +24 -1
- package/build/server.js +3 -2
- package/build/sync/cleanup.js +61 -0
- package/build/sync/course-sync.js +649 -0
- package/build/sync/errors.js +8 -0
- package/build/sync/lock.js +123 -0
- package/build/sync/manifest.js +106 -0
- package/build/sync/names.js +158 -0
- package/build/sync/summary.js +70 -0
- package/build/sync/sync-cli.js +190 -0
- package/build/tools/download-course-files.js +86 -0
- package/build/tools/download-dropbox-submission-file.js +7 -1
- package/build/tools/download-file.js +24 -9
- package/build/tools/get-course-content.js +28 -11
- package/build/tools/get-syllabus.js +5 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +18 -0
- package/build/tools/tool-helpers.js +5 -0
- package/build/utils/download-helpers.js +67 -4
- package/build/utils/sensitive-paths.js +237 -0
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
* Where a download may be written. The destination is chosen through an AI
|
|
7
|
+
* client, and the file and folder names inside it come from a course, so a
|
|
8
|
+
* destination that is the home folder, a hidden folder (where settings and
|
|
9
|
+
* credentials live), an application-data folder or a system folder would let
|
|
10
|
+
* a course plant files where they do real harm. Everything else is allowed:
|
|
11
|
+
* the user's folders, cloud-sync folders, temp folders, /srv, /data,
|
|
12
|
+
* /workspace, /opt, external drives.
|
|
13
|
+
*
|
|
14
|
+
* The rule is judged on the native real path, so a symbolic link into a
|
|
15
|
+
* refused place is refused. The person who runs the server can allow more
|
|
16
|
+
* with D2L_DOWNLOAD_ROOTS (see downloadRootsFromEnv); a tool argument cannot.
|
|
17
|
+
*/
|
|
18
|
+
import { lstat, readlink, realpath } from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { log } from "./logger.js";
|
|
23
|
+
let configured = {};
|
|
24
|
+
/** Set the policy for this process. Only tests call it: production uses the defaults. */
|
|
25
|
+
export function configureDestinationPolicy(policy) {
|
|
26
|
+
configured = policy;
|
|
27
|
+
}
|
|
28
|
+
// The native variant expands Windows 8.3 short names; fs/promises has none.
|
|
29
|
+
const realpathNative = promisify(realpath.native);
|
|
30
|
+
const lstatAsync = promisify(lstat);
|
|
31
|
+
const readlinkAsync = promisify(readlink);
|
|
32
|
+
const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
|
|
33
|
+
const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
|
|
34
|
+
/** \\?\ and \\.\ paths skip Windows' own name handling, so they are not accepted. */
|
|
35
|
+
const DEVICE_PREFIX = /^[\\/]{2}[?.][\\/]/;
|
|
36
|
+
class LinkLoopError extends Error {
|
|
37
|
+
}
|
|
38
|
+
const MAX_LINK_DEPTH = 40;
|
|
39
|
+
/**
|
|
40
|
+
* The native real path of `target` (8.3 names expanded on Windows). A part
|
|
41
|
+
* that does not exist yet is kept as written, but a dangling symbolic link is
|
|
42
|
+
* followed to where it points, so what is judged is where a write would land.
|
|
43
|
+
*/
|
|
44
|
+
async function resolveReal(target, depth = 0) {
|
|
45
|
+
if (depth > MAX_LINK_DEPTH)
|
|
46
|
+
throw new LinkLoopError();
|
|
47
|
+
let current = path.resolve(target);
|
|
48
|
+
const tail = [];
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
return path.join(await realpathNative(current), ...[...tail].reverse());
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const code = error.code;
|
|
55
|
+
if (code === "ELOOP")
|
|
56
|
+
throw new LinkLoopError();
|
|
57
|
+
// "Not there yet" and "a folder above it that this user may not look into" (/root for a normal user)
|
|
58
|
+
// are judged by the nearest part that can be resolved, plus the rest of the path as written.
|
|
59
|
+
// Anything else is not guessed past: the caller gets the error and refuses.
|
|
60
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM" && code !== "ENOTDIR")
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const link = await lstatAsync(current).then((stat) => (stat.isSymbolicLink() ? stat : null), () => null);
|
|
64
|
+
if (link) {
|
|
65
|
+
const pointsAt = path.resolve(path.dirname(current), await readlinkAsync(current));
|
|
66
|
+
return path.join(await resolveReal(pointsAt, depth + 1), ...[...tail].reverse());
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(current);
|
|
69
|
+
if (parent === current)
|
|
70
|
+
return path.resolve(target);
|
|
71
|
+
tail.push(path.basename(current));
|
|
72
|
+
current = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The components of `child` below `parent`, or null when it is not inside it. */
|
|
76
|
+
function below(child, parent) {
|
|
77
|
+
const rel = path.relative(fold(parent), fold(child));
|
|
78
|
+
if (rel === "")
|
|
79
|
+
return [];
|
|
80
|
+
if (rel.split(path.sep)[0] === ".." || path.isAbsolute(rel))
|
|
81
|
+
return null;
|
|
82
|
+
// Original case, so a later check sees the real names.
|
|
83
|
+
return path.relative(parent, child).split(path.sep);
|
|
84
|
+
}
|
|
85
|
+
const components = (p) => path.relative(path.parse(p).root, p).split(path.sep).filter(Boolean);
|
|
86
|
+
const lower = (s) => s.toLowerCase();
|
|
87
|
+
const WINDOWS_SYSTEM_FOLDERS = new Set(["windows", "program files", "program files (x86)", "programdata", "system volume information", "$recycle.bin"]);
|
|
88
|
+
const POSIX_SYSTEM_FOLDERS = ["/etc", "/usr", "/bin", "/sbin", "/System", "/Library", "/Applications", "/boot", "/dev", "/proc", "/sys", "/private/etc"];
|
|
89
|
+
/** Temporary folders under /var (macOS keeps its per-user temp there), the only part of it that is allowed. */
|
|
90
|
+
const VAR_TEMP_FOLDERS = ["/var/folders", "/var/tmp", "/private/var/folders", "/private/var/tmp"];
|
|
91
|
+
const REFUSED_DEVICE = "it is a Windows device path (\\\\?\\ or \\\\.\\)";
|
|
92
|
+
const REFUSED_ROOT = "it is the root of a drive";
|
|
93
|
+
const REFUSED_HOME = "it is your home directory itself";
|
|
94
|
+
/**
|
|
95
|
+
* True when any AppData folder in the path is not just the way to its own
|
|
96
|
+
* Local\Temp. AppData holds applications' data and the Startup folder; only
|
|
97
|
+
* Temp is allowed, and an AppData that appears again below a Temp folder (a
|
|
98
|
+
* fake home kept in Temp) is judged on its own.
|
|
99
|
+
*/
|
|
100
|
+
function appDataOutsideTemp(names) {
|
|
101
|
+
return names.some((name, i) => name === "appdata" && !(names[i + 1] === "local" && names[i + 2] === "temp"));
|
|
102
|
+
}
|
|
103
|
+
/** Why a real path is refused by the built-in rules, or null. `home` is a real path. */
|
|
104
|
+
async function refusedByRules(real, home) {
|
|
105
|
+
const parts = components(real);
|
|
106
|
+
const hiddenPart = parts.find((part) => part.startsWith("."));
|
|
107
|
+
if (hiddenPart !== undefined)
|
|
108
|
+
return `its path goes through the hidden folder "${hiddenPart}", where settings and credentials live`;
|
|
109
|
+
const inHome = below(real, home);
|
|
110
|
+
if (process.platform === "darwin" && inHome && inHome.length > 0 && lower(inHome[0]) === "library") {
|
|
111
|
+
const allowed = (inHome.length >= 2 && lower(inHome[1]) === "cloudstorage") || (inHome.length >= 2 && lower(inHome[1]) === "mobile documents");
|
|
112
|
+
if (!allowed)
|
|
113
|
+
return "it is inside ~/Library, where applications keep their data (only ~/Library/CloudStorage and ~/Library/Mobile Documents are allowed)";
|
|
114
|
+
}
|
|
115
|
+
for (const system of POSIX_SYSTEM_FOLDERS) {
|
|
116
|
+
if (below(real, system) || below(real, await resolveReal(system)))
|
|
117
|
+
return `it is inside ${system}, a system folder`;
|
|
118
|
+
}
|
|
119
|
+
const first = parts[0] ?? "";
|
|
120
|
+
if (path.sep === "/" && /^lib(32|64|x32)?$/.test(first))
|
|
121
|
+
return `it is inside /${first}, a system folder`;
|
|
122
|
+
if (path.sep === "/" && (below(real, "/var") || below(real, "/private/var"))) {
|
|
123
|
+
if (!VAR_TEMP_FOLDERS.some((temp) => below(real, temp) || below(real, `/private${temp}`)))
|
|
124
|
+
return "it is inside /var, a system folder (only its temporary folders are allowed)";
|
|
125
|
+
}
|
|
126
|
+
if (path.sep === "/" && below(real, "/root") && !(below(home, "/root")))
|
|
127
|
+
return "it is inside /root, the administrator's home";
|
|
128
|
+
if (process.platform === "win32") {
|
|
129
|
+
const names = parts.map(lower);
|
|
130
|
+
if (WINDOWS_SYSTEM_FOLDERS.has(names[0] ?? ""))
|
|
131
|
+
return `it is inside ${parts[0]}, a Windows system folder`;
|
|
132
|
+
if (appDataOutsideTemp(names))
|
|
133
|
+
return "it is inside AppData, where applications keep their data and the Startup folder";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Windows drives seen from WSL: /mnt/c/Windows, /mnt/c/Users/<name>/AppData.
|
|
137
|
+
const wsl = /^\/mnt\/[a-z]\/(.+)$/i.exec(real.split(path.sep).join("/"));
|
|
138
|
+
if (wsl) {
|
|
139
|
+
const names = wsl[1].split("/").map(lower);
|
|
140
|
+
if (WINDOWS_SYSTEM_FOLDERS.has(names[0]))
|
|
141
|
+
return `it is inside a Windows system folder (${wsl[1].split("/")[0]})`;
|
|
142
|
+
if (appDataOutsideTemp(names))
|
|
143
|
+
return "it is inside a Windows AppData folder";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Why `dir` may not be a download destination, or null when it may. Refused:
|
|
150
|
+
* device paths, a drive root, the home folder itself, any path that goes
|
|
151
|
+
* through a hidden (dot) folder, ~/Library (except CloudStorage and Mobile
|
|
152
|
+
* Documents), system folders (/etc, /usr, /bin, /sbin, /System, /Library,
|
|
153
|
+
* /Applications, /boot, /dev, /proc, /sys, /lib*, /var apart from its temp
|
|
154
|
+
* folders, /root, and on Windows or under WSL Windows, Program Files,
|
|
155
|
+
* ProgramData and AppData). A destination inside a root listed in
|
|
156
|
+
* D2L_DOWNLOAD_ROOTS is allowed even where those rules would refuse it,
|
|
157
|
+
* but never a device path, a drive root or the home folder itself.
|
|
158
|
+
*/
|
|
159
|
+
export async function sensitiveDestination(dir, options = {}) {
|
|
160
|
+
if (DEVICE_PREFIX.test(dir))
|
|
161
|
+
return REFUSED_DEVICE;
|
|
162
|
+
const policy = { ...configured, ...options };
|
|
163
|
+
try {
|
|
164
|
+
const real = await resolveReal(dir);
|
|
165
|
+
if (DEVICE_PREFIX.test(real))
|
|
166
|
+
return REFUSED_DEVICE;
|
|
167
|
+
if (path.parse(real).root === real)
|
|
168
|
+
return REFUSED_ROOT;
|
|
169
|
+
const realHome = await resolveReal(policy.home ?? os.homedir());
|
|
170
|
+
if (fold(real) === fold(realHome))
|
|
171
|
+
return REFUSED_HOME;
|
|
172
|
+
const operatorRoots = [...(policy.allowRoots ?? []), ...(await downloadRootsFromEnv(undefined, policy.home))];
|
|
173
|
+
for (const root of operatorRoots) {
|
|
174
|
+
if (below(real, await resolveReal(root)))
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return await refusedByRules(real, realHome);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof LinkLoopError)
|
|
181
|
+
return "it involves a symbolic-link loop";
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const warnedRoots = new Set();
|
|
186
|
+
/** Let the next check warn again about an ignored entry. For tests. */
|
|
187
|
+
export function resetDownloadRootWarnings() {
|
|
188
|
+
warnedRoots.clear();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The extra allowed roots named by D2L_DOWNLOAD_ROOTS, a path.delimiter
|
|
192
|
+
* separated list, each resolved to its real path. It is read from the
|
|
193
|
+
* server's own environment, which a tool argument cannot set. A listed root
|
|
194
|
+
* overrides every rule above, even inside the home folder (a hidden or
|
|
195
|
+
* ~/Library folder the user really wants), except that an entry which is a
|
|
196
|
+
* relative path, a device path, a drive root, the home folder itself or a
|
|
197
|
+
* folder that holds the home folder is ignored with a warning.
|
|
198
|
+
*/
|
|
199
|
+
export async function downloadRootsFromEnv(value = process.env.D2L_DOWNLOAD_ROOTS, home = os.homedir()) {
|
|
200
|
+
const roots = [];
|
|
201
|
+
const realHome = await resolveReal(home);
|
|
202
|
+
for (const entry of (value ?? "").split(path.delimiter).map((e) => e.trim()).filter(Boolean)) {
|
|
203
|
+
let reason = null;
|
|
204
|
+
let real = entry;
|
|
205
|
+
if (!path.isAbsolute(entry))
|
|
206
|
+
reason = "it is not an absolute path";
|
|
207
|
+
else if (DEVICE_PREFIX.test(entry))
|
|
208
|
+
reason = "it is a Windows device path";
|
|
209
|
+
else {
|
|
210
|
+
try {
|
|
211
|
+
real = await resolveReal(entry);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
reason = "it could not be resolved";
|
|
215
|
+
}
|
|
216
|
+
if (!reason && path.parse(real).root === real)
|
|
217
|
+
reason = "it is the root of a drive";
|
|
218
|
+
else if (!reason && fold(real) === fold(realHome))
|
|
219
|
+
reason = "it is your home folder itself";
|
|
220
|
+
else if (!reason && below(realHome, real))
|
|
221
|
+
reason = "it contains your home folder";
|
|
222
|
+
}
|
|
223
|
+
if (reason) {
|
|
224
|
+
if (!warnedRoots.has(entry)) {
|
|
225
|
+
warnedRoots.add(entry);
|
|
226
|
+
log("WARN", `[config] Ignoring D2L_DOWNLOAD_ROOTS entry ${JSON.stringify(entry)}: ${reason}.`);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
roots.push(real);
|
|
231
|
+
}
|
|
232
|
+
return roots;
|
|
233
|
+
}
|
|
234
|
+
/** The refusal shown for a protected destination: the rule that refused it, and what to do. */
|
|
235
|
+
export function sensitiveDestinationMessage(dir, reason) {
|
|
236
|
+
return `Cannot save into ${dir}: ${reason}. Choose a folder such as ~/Documents/Brightspace, or have the person who runs the server add the folder to D2L_DOWNLOAD_ROOTS.`;
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
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",
|