brightspace-d2l-mcp 1.0.0
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/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- package/package.json +104 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readStoredConfig, writeStoredConfig } from "../utils/config.js";
|
|
3
|
+
import { loginWithBrowser, LoginError, NoBrowserError, TokenManager } from "../auth/index.js";
|
|
4
|
+
import { SERVER_NAME } from "../utils/paths.js";
|
|
5
|
+
import { resolveSchool } from "./school.js";
|
|
6
|
+
import { addToClient, allowToolsInClaudeCode, detectClients, isClaudeDesktopRunning, quitClaudeDesktop, serverEntry, } from "./clients.js";
|
|
7
|
+
import * as ui from "./ui.js";
|
|
8
|
+
export async function runSetup(flags) {
|
|
9
|
+
if (flags.yes)
|
|
10
|
+
ui.setNonInteractive();
|
|
11
|
+
console.log(`\n${ui.bold("Brightspace for your AI apps")} ${ui.dim("· setup takes about a minute")}`);
|
|
12
|
+
ui.step(1, 3, "Your school");
|
|
13
|
+
const saved = readStoredConfig().baseUrl;
|
|
14
|
+
let baseUrl = null;
|
|
15
|
+
let input = flags.school ?? "";
|
|
16
|
+
if (!input && saved) {
|
|
17
|
+
if (await ui.confirm(`Use ${ui.bold(saved)}?`))
|
|
18
|
+
input = saved;
|
|
19
|
+
}
|
|
20
|
+
while (!baseUrl) {
|
|
21
|
+
if (!input) {
|
|
22
|
+
if (!ui.interactive) {
|
|
23
|
+
ui.fail("No school given. Run again with --school <your Brightspace address>.");
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
ui.info(ui.dim("Open Brightspace in your browser and copy the link from the address bar."));
|
|
27
|
+
ui.info(ui.dim("Examples: yourschool.brightspace.com, yourschool.desire2learn.com, or any link to a course page."));
|
|
28
|
+
input = await ui.ask("Brightspace address:");
|
|
29
|
+
}
|
|
30
|
+
const result = await resolveSchool(input);
|
|
31
|
+
if (result.ok) {
|
|
32
|
+
baseUrl = result.baseUrl;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
ui.fail(result.reason);
|
|
36
|
+
if (!ui.interactive)
|
|
37
|
+
return 1;
|
|
38
|
+
input = "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
writeStoredConfig({ baseUrl });
|
|
42
|
+
ui.ok(`Brightspace found at ${ui.bold(baseUrl)}`);
|
|
43
|
+
ui.step(2, 3, "Sign in");
|
|
44
|
+
if (flags.skipLogin) {
|
|
45
|
+
ui.info(ui.dim("Skipped. You'll be asked to sign in the first time your AI uses Brightspace."));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const signedIn = await signIn(baseUrl);
|
|
49
|
+
if (!signedIn)
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
ui.step(3, 3, "Connect your AI apps");
|
|
53
|
+
const found = detectClients().filter((c) => !flags.apps || flags.apps.includes(c.id));
|
|
54
|
+
if (found.length === 0 && flags.apps) {
|
|
55
|
+
ui.info(ui.dim("No apps selected. Add this to any MCP app's settings:"));
|
|
56
|
+
printManualConfig();
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (found.length === 0) {
|
|
60
|
+
ui.warn("No supported AI apps found (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code).");
|
|
61
|
+
printManualConfig();
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const chosen = [];
|
|
65
|
+
ui.info(`Found: ${found.map((c) => ui.bold(c.name)).join(", ")}`);
|
|
66
|
+
if (found.length > 1 && (await ui.confirm("Connect all of them?"))) {
|
|
67
|
+
chosen.push(...found);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
for (const client of found) {
|
|
71
|
+
if (found.length === 1 || (await ui.confirm(`Connect ${client.name}?`)))
|
|
72
|
+
chosen.push(client);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const connected = [];
|
|
76
|
+
for (const client of chosen) {
|
|
77
|
+
try {
|
|
78
|
+
if (await connect(client))
|
|
79
|
+
connected.push(client.name);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
ui.fail(`${client.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n${ui.bold(ui.green("All set!"))}`);
|
|
86
|
+
if (connected.length) {
|
|
87
|
+
ui.info(`Connected: ${connected.join(", ")}.`);
|
|
88
|
+
const needsRestart = connected.filter((n) => n !== "Claude Desktop");
|
|
89
|
+
if (needsRestart.length)
|
|
90
|
+
ui.info(ui.dim(`Restart ${needsRestart.join(", ")} (or reload its window) to load Brightspace.`));
|
|
91
|
+
}
|
|
92
|
+
ui.info(`Try asking: ${ui.cyan('"What should I work on right now?"')}`);
|
|
93
|
+
console.log();
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
async function signIn(baseUrl) {
|
|
97
|
+
ui.info("A browser window will open on your Brightspace sign-in page.");
|
|
98
|
+
ui.info("Sign in the way you normally do (school account, text code, authenticator...).");
|
|
99
|
+
ui.info(ui.dim("The window closes by itself once you're in. No password is saved by this tool."));
|
|
100
|
+
if (ui.interactive)
|
|
101
|
+
await ui.ask(ui.dim("Press Enter to open the browser..."));
|
|
102
|
+
for (;;) {
|
|
103
|
+
try {
|
|
104
|
+
const { token, displayName } = await loginWithBrowser({
|
|
105
|
+
baseUrl,
|
|
106
|
+
mode: "interactive",
|
|
107
|
+
onStatus: (message) => ui.info(ui.dim(message)),
|
|
108
|
+
});
|
|
109
|
+
await new TokenManager({ baseUrl }).setToken(token);
|
|
110
|
+
ui.ok(displayName ? `Signed in as ${ui.bold(displayName)}` : "Signed in");
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof NoBrowserError) {
|
|
115
|
+
ui.fail(error.message);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const message = error instanceof LoginError ? error.message : `Sign-in failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
119
|
+
ui.fail(message);
|
|
120
|
+
if (!(await ui.confirm("Try signing in again?"))) {
|
|
121
|
+
ui.info(ui.dim("You can finish later with: npx brightspace-d2l-mcp login"));
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function connect(client) {
|
|
128
|
+
if (client.id === "claude-desktop")
|
|
129
|
+
return connectClaudeDesktop(client);
|
|
130
|
+
if (client.id === "claude-code") {
|
|
131
|
+
// Claude Code rewrites ~/.claude.json while running; its own CLI is the safe way in.
|
|
132
|
+
if (!addViaClaudeCli())
|
|
133
|
+
addToClient(client);
|
|
134
|
+
allowToolsInClaudeCode();
|
|
135
|
+
ui.ok("Claude Code connected (tools allowed without asking each time)");
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
addToClient(client);
|
|
139
|
+
ui.ok(`${client.name} connected`);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
async function connectClaudeDesktop(client) {
|
|
143
|
+
// Claude Desktop saves its config from memory while open, which would erase our entry.
|
|
144
|
+
let relaunch = null;
|
|
145
|
+
if (isClaudeDesktopRunning()) {
|
|
146
|
+
if (await ui.confirm("Claude Desktop is open. Restart it now to add Brightspace?")) {
|
|
147
|
+
relaunch = await quitClaudeDesktop();
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
ui.info("Quit Claude Desktop completely (tray icon → Quit), then press Enter.");
|
|
151
|
+
await ui.ask(ui.dim("Press Enter when it's closed..."));
|
|
152
|
+
}
|
|
153
|
+
if (isClaudeDesktopRunning()) {
|
|
154
|
+
ui.warn("Claude Desktop is still running, so it may undo this change. Quit it and run setup again if Brightspace doesn't appear.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
addToClient(client);
|
|
158
|
+
relaunch?.();
|
|
159
|
+
ui.ok(`Claude Desktop connected${relaunch ? " and restarted" : ""}`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
function addViaClaudeCli() {
|
|
163
|
+
const entry = serverEntry();
|
|
164
|
+
const json = JSON.stringify({ type: "stdio", command: entry.command, args: entry.args });
|
|
165
|
+
try {
|
|
166
|
+
const shell = process.platform === "win32";
|
|
167
|
+
try {
|
|
168
|
+
execFileSync("claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"], { stdio: "ignore", shell, timeout: 30_000 });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Not previously added.
|
|
172
|
+
}
|
|
173
|
+
execFileSync("claude", ["mcp", "add-json", SERVER_NAME, shell ? `"${json.replace(/"/g, '\\"')}"` : json, "--scope", "user"], {
|
|
174
|
+
stdio: "ignore",
|
|
175
|
+
shell,
|
|
176
|
+
timeout: 30_000,
|
|
177
|
+
});
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function printManualConfig() {
|
|
185
|
+
const entry = serverEntry();
|
|
186
|
+
ui.info("Add this to your AI app's MCP settings:");
|
|
187
|
+
console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: entry } }, null, 2).replace(/^/gm, " "));
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=setup.js.map
|
package/build/cli/ui.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3
|
+
const paint = (code) => (text) => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
4
|
+
export const bold = paint("1");
|
|
5
|
+
export const dim = paint("2");
|
|
6
|
+
export const green = paint("32");
|
|
7
|
+
export const yellow = paint("33");
|
|
8
|
+
export const red = paint("31");
|
|
9
|
+
export const cyan = paint("36");
|
|
10
|
+
export const ok = (text) => console.log(` ${green("✔")} ${text}`);
|
|
11
|
+
export const warn = (text) => console.log(` ${yellow("!")} ${text}`);
|
|
12
|
+
export const fail = (text) => console.log(` ${red("✖")} ${text}`);
|
|
13
|
+
export const info = (text) => console.log(` ${text}`);
|
|
14
|
+
export const step = (n, total, title) => console.log(`\n${bold(cyan(`Step ${n}/${total}`))} ${bold(title)}`);
|
|
15
|
+
export let interactive = process.stdin.isTTY === true;
|
|
16
|
+
export function setNonInteractive() {
|
|
17
|
+
interactive = false;
|
|
18
|
+
}
|
|
19
|
+
export async function ask(question, fallback = "") {
|
|
20
|
+
if (!interactive)
|
|
21
|
+
return fallback;
|
|
22
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
23
|
+
try {
|
|
24
|
+
const answer = (await rl.question(` ${question} `)).trim();
|
|
25
|
+
return answer || fallback;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rl.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function confirm(question, defaultYes = true) {
|
|
32
|
+
if (!interactive)
|
|
33
|
+
return defaultYes;
|
|
34
|
+
const answer = (await ask(`${question} ${dim(defaultYes ? "(Y/n)" : "(y/N)")}`)).toLowerCase();
|
|
35
|
+
if (!answer)
|
|
36
|
+
return defaultYes;
|
|
37
|
+
return answer.startsWith("y");
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=ui.js.map
|
package/build/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const CLI_COMMANDS = new Set(["setup", "login", "status", "logout", "uninstall", "serve", "config", "help", "--help", "-h", "--version", "-v"]);
|
|
3
|
+
const command = process.argv[2];
|
|
4
|
+
if (command && CLI_COMMANDS.has(command)) {
|
|
5
|
+
const { runCli } = await import("./cli/main.js");
|
|
6
|
+
process.exitCode = await runCli(command, process.argv.slice(3));
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
const { startServer } = await import("./server.js");
|
|
10
|
+
await startServer();
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
package/build/server.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { enableStdoutGuard, log } from "./utils/logger.js";
|
|
7
|
+
import { loadConfig, readStoredConfig, writeStoredConfig } from "./utils/config.js";
|
|
8
|
+
import { packageVersion } from "./utils/version.js";
|
|
9
|
+
import { TokenManager, Authenticator, AuthRequiredError } from "./auth/index.js";
|
|
10
|
+
import { SETUP_COMMAND } from "./utils/paths.js";
|
|
11
|
+
import { D2LApiClient } from "./api/index.js";
|
|
12
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetTodo, } from "./tools/index.js";
|
|
13
|
+
function notConfiguredClient(client) {
|
|
14
|
+
return new Proxy(client, {
|
|
15
|
+
get(target, property) {
|
|
16
|
+
if (property === "get" || property === "getRaw") {
|
|
17
|
+
return async () => {
|
|
18
|
+
throw new AuthRequiredError("notConfigured", `Brightspace is not set up yet. Run \`${SETUP_COMMAND}\` in a terminal.`);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const value = Reflect.get(target, property);
|
|
22
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// One API client and session for the whole process, shared by every connection.
|
|
27
|
+
function createBackend() {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
if (!config.baseUrl)
|
|
30
|
+
log("WARN", "No school configured yet; tools will explain how to run setup");
|
|
31
|
+
const servers = new Set();
|
|
32
|
+
const tokenManager = new TokenManager({ baseUrl: config.baseUrl || undefined, tokenTtl: config.tokenTtl });
|
|
33
|
+
const authenticator = new Authenticator({
|
|
34
|
+
baseUrl: config.baseUrl,
|
|
35
|
+
tokenTtl: config.tokenTtl,
|
|
36
|
+
autoLogin: config.autoLogin,
|
|
37
|
+
tokenManager,
|
|
38
|
+
notify: (message) => {
|
|
39
|
+
log("INFO", message);
|
|
40
|
+
for (const server of servers) {
|
|
41
|
+
void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// Sign-in waits for the first tool call, so starting an AI app never opens a browser by itself.
|
|
46
|
+
const client = new D2LApiClient({
|
|
47
|
+
baseUrl: config.baseUrl || "https://not-configured.invalid",
|
|
48
|
+
tokenManager,
|
|
49
|
+
onAuthExpired: () => authenticator.run(),
|
|
50
|
+
});
|
|
51
|
+
return { config, apiClient: config.baseUrl ? client : notConfiguredClient(client), servers };
|
|
52
|
+
}
|
|
53
|
+
function createMcpServer({ config, apiClient }) {
|
|
54
|
+
const server = new McpServer({
|
|
55
|
+
name: "brightspace",
|
|
56
|
+
version: packageVersion(),
|
|
57
|
+
description: "D2L Brightspace for students: what to do next, due dates with submission status, grades, content, announcements.",
|
|
58
|
+
}, { capabilities: { logging: {} } });
|
|
59
|
+
registerGetTodo(server, apiClient, config);
|
|
60
|
+
registerGetMyCourses(server, apiClient, config);
|
|
61
|
+
registerGetUpcomingDueDates(server, apiClient, config);
|
|
62
|
+
registerGetMyGrades(server, apiClient, config);
|
|
63
|
+
registerGetAnnouncements(server, apiClient, config);
|
|
64
|
+
registerGetAssignments(server, apiClient, config);
|
|
65
|
+
registerGetAssignmentFiles(server, apiClient, config.baseUrl);
|
|
66
|
+
registerGetCourseContent(server, apiClient);
|
|
67
|
+
registerDownloadFile(server, apiClient);
|
|
68
|
+
registerGetClasslistEmails(server, apiClient);
|
|
69
|
+
registerGetRoster(server, apiClient);
|
|
70
|
+
registerGetSyllabus(server, apiClient);
|
|
71
|
+
registerGetDiscussions(server, apiClient);
|
|
72
|
+
return server;
|
|
73
|
+
}
|
|
74
|
+
export async function startServer() {
|
|
75
|
+
// stdout carries the MCP protocol; anything else printed there corrupts it.
|
|
76
|
+
enableStdoutGuard();
|
|
77
|
+
process.on("unhandledRejection", (reason) => log("ERROR", "Unhandled promise rejection", reason));
|
|
78
|
+
try {
|
|
79
|
+
const backend = createBackend();
|
|
80
|
+
const server = createMcpServer(backend);
|
|
81
|
+
backend.servers.add(server);
|
|
82
|
+
await server.connect(new StdioServerTransport());
|
|
83
|
+
log("INFO", `brightspace-d2l-mcp v${packageVersion()} running for ${backend.config.baseUrl || "(not set up)"}`);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
log("ERROR", "MCP server failed to start", error);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const shutdown = () => process.exit(0);
|
|
90
|
+
process.on("SIGINT", shutdown);
|
|
91
|
+
process.on("SIGTERM", shutdown);
|
|
92
|
+
}
|
|
93
|
+
export function httpAccessToken() {
|
|
94
|
+
const stored = readStoredConfig().httpToken;
|
|
95
|
+
if (stored)
|
|
96
|
+
return stored;
|
|
97
|
+
const token = randomBytes(24).toString("base64url");
|
|
98
|
+
writeStoredConfig({ httpToken: token });
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
function tokenMatches(given, expected) {
|
|
102
|
+
if (!given)
|
|
103
|
+
return false;
|
|
104
|
+
const a = Buffer.from(given);
|
|
105
|
+
const b = Buffer.from(expected);
|
|
106
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Streamable HTTP transport for clients that can't launch a local process:
|
|
110
|
+
* ChatGPT connectors (through a tunnel), hosted agents and HTTP-only frameworks.
|
|
111
|
+
* The token is accepted as a Bearer header or as the last path segment (/mcp/<token>),
|
|
112
|
+
* since some apps only let you paste a URL.
|
|
113
|
+
*/
|
|
114
|
+
export async function startHttpServer(options) {
|
|
115
|
+
const isLocal = ["127.0.0.1", "localhost", "::1"].includes(options.host);
|
|
116
|
+
if (options.noAuth && !isLocal)
|
|
117
|
+
throw new Error("--no-auth is only allowed when listening on localhost.");
|
|
118
|
+
const backend = createBackend();
|
|
119
|
+
const token = options.noAuth ? null : httpAccessToken();
|
|
120
|
+
const handle = async (req, res) => {
|
|
121
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
122
|
+
const [, first, pathToken] = url.pathname.split("/");
|
|
123
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
124
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, version: packageVersion() }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (first !== "mcp") {
|
|
128
|
+
res.writeHead(404).end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (token) {
|
|
132
|
+
const header = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
133
|
+
if (!tokenMatches(header, token) && !tokenMatches(pathToken, token)) {
|
|
134
|
+
res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" })
|
|
135
|
+
.end(JSON.stringify({ error: "Missing or wrong access token." }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (req.method !== "POST") {
|
|
140
|
+
res.writeHead(405, { allow: "POST" }).end();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// Stateless: a fresh MCP server per request, sharing the one signed-in backend.
|
|
144
|
+
const server = createMcpServer(backend);
|
|
145
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
146
|
+
backend.servers.add(server);
|
|
147
|
+
res.on("close", () => {
|
|
148
|
+
backend.servers.delete(server);
|
|
149
|
+
void transport.close();
|
|
150
|
+
void server.close();
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
await server.connect(transport);
|
|
154
|
+
await transport.handleRequest(req, res);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
log("ERROR", "HTTP request failed", error);
|
|
158
|
+
if (!res.headersSent)
|
|
159
|
+
res.writeHead(500).end();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const httpServer = createHttpServer((req, res) => void handle(req, res));
|
|
163
|
+
await new Promise((resolve, reject) => {
|
|
164
|
+
httpServer.once("error", reject);
|
|
165
|
+
httpServer.listen(options.port, options.host, resolve);
|
|
166
|
+
});
|
|
167
|
+
const address = httpServer.address();
|
|
168
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
169
|
+
const displayHost = options.host.includes(":") ? `[${options.host}]` : options.host;
|
|
170
|
+
return {
|
|
171
|
+
url: `http://${displayHost}:${port}/mcp${token ? `/${token}` : ""}`,
|
|
172
|
+
close: () => new Promise((resolve) => httpServer.close(() => resolve())),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { DownloadFileSchema } from "./schemas.js";
|
|
2
|
+
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
3
|
+
import { log } from "../utils/logger.js";
|
|
4
|
+
import { validateContentId, MAX_FILE_SIZE, } from "../utils/file-validator.js";
|
|
5
|
+
import { secureDownload } from "../utils/download-helpers.js";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export function registerDownloadFile(server, apiClient) {
|
|
9
|
+
server.registerTool("download_file", {
|
|
10
|
+
title: "Download File",
|
|
11
|
+
description: "Download a file from course content or assignment submissions to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content or dropbox submissions. IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original.",
|
|
12
|
+
inputSchema: DownloadFileSchema,
|
|
13
|
+
}, async (args) => {
|
|
14
|
+
try {
|
|
15
|
+
log("DEBUG", "download_file tool called", { args });
|
|
16
|
+
// Parse and validate input
|
|
17
|
+
const { courseId, topicId, folderId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
|
|
18
|
+
// Validate courseId
|
|
19
|
+
validateContentId(courseId);
|
|
20
|
+
// Validate download path is absolute
|
|
21
|
+
if (!path.isAbsolute(downloadPath)) {
|
|
22
|
+
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
|
|
23
|
+
}
|
|
24
|
+
// Validate download directory exists and is a directory
|
|
25
|
+
try {
|
|
26
|
+
const stats = await fs.stat(downloadPath);
|
|
27
|
+
if (!stats.isDirectory()) {
|
|
28
|
+
return errorResponse(`Download path is not a directory: ${downloadPath}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error?.code === "ENOENT") {
|
|
33
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}`);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
// Determine download source
|
|
38
|
+
if (topicId !== undefined) {
|
|
39
|
+
// Content file download
|
|
40
|
+
validateContentId(topicId);
|
|
41
|
+
return await downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename);
|
|
42
|
+
}
|
|
43
|
+
else if (folderId !== undefined && fileId !== undefined) {
|
|
44
|
+
// Submission file download
|
|
45
|
+
validateContentId(folderId);
|
|
46
|
+
validateContentId(fileId);
|
|
47
|
+
return await downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
return errorResponse("Either topicId (for content files) or both folderId and fileId (for submission files) must be provided");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return sanitizeError(error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function parseContentDispositionFilename(disposition) {
|
|
59
|
+
const extended = disposition.match(/filename\*\s*=\s*([^;]+)/i);
|
|
60
|
+
if (extended?.[1]) {
|
|
61
|
+
const value = extended[1].trim();
|
|
62
|
+
const parts = value.split("'");
|
|
63
|
+
const encoded = parts.length >= 3 ? parts.slice(2).join("'") : value;
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(encoded);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return encoded;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const plain = disposition.match(/filename\s*=\s*("([^"]*)"|[^;\n]*)/i);
|
|
72
|
+
if (plain) {
|
|
73
|
+
const value = (plain[2] ?? plain[1] ?? "").trim();
|
|
74
|
+
if (value)
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
async function downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
|
|
80
|
+
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
81
|
+
// Build download URL using D2L API path helper
|
|
82
|
+
const apiPath = apiClient.le(courseId, `/content/topics/${topicId}/file`);
|
|
83
|
+
// Fetch file using getRaw (returns Response object, not parsed JSON)
|
|
84
|
+
const response = await apiClient.getRaw(apiPath);
|
|
85
|
+
// Check Content-Length BEFORE downloading body (prevent memory exhaustion)
|
|
86
|
+
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
87
|
+
if (contentLength > MAX_FILE_SIZE) {
|
|
88
|
+
return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
89
|
+
}
|
|
90
|
+
// Get filename from Content-Disposition header
|
|
91
|
+
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
92
|
+
const filename = parseContentDispositionFilename(disposition) ?? "download";
|
|
93
|
+
log("DEBUG", `Content-Disposition filename: ${filename}`);
|
|
94
|
+
// Download body as buffer
|
|
95
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
96
|
+
// Double-check actual size
|
|
97
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
98
|
+
return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
99
|
+
}
|
|
100
|
+
// Use custom filename if provided, otherwise use Content-Disposition filename
|
|
101
|
+
const originalFilename = filename;
|
|
102
|
+
const effectiveFilename = customFilename || filename;
|
|
103
|
+
// Use secureDownload for path traversal prevention, file type validation, and conflict resolution
|
|
104
|
+
const result = await secureDownload({
|
|
105
|
+
targetDir: downloadPath,
|
|
106
|
+
filename: effectiveFilename,
|
|
107
|
+
data: buffer,
|
|
108
|
+
});
|
|
109
|
+
log("INFO", `File downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
110
|
+
return toolResponse({
|
|
111
|
+
success: true,
|
|
112
|
+
filePath: result.path,
|
|
113
|
+
fileSize: result.size,
|
|
114
|
+
mimeType: result.mime,
|
|
115
|
+
originalFilename,
|
|
116
|
+
message: `File downloaded successfully to ${result.path}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename) {
|
|
120
|
+
log("INFO", `Downloading submission file: courseId=${courseId}, folderId=${folderId}, fileId=${fileId}`);
|
|
121
|
+
// First, fetch the submission to get file metadata
|
|
122
|
+
const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/mysubmissions/`);
|
|
123
|
+
const submissions = await apiClient.get(submissionsPath);
|
|
124
|
+
if (!submissions || submissions.length === 0) {
|
|
125
|
+
return errorResponse("No submissions found for this assignment. Upload a submission first.");
|
|
126
|
+
}
|
|
127
|
+
// Find the file in the submission
|
|
128
|
+
const submission = submissions[0];
|
|
129
|
+
const file = submission.Files.find((f) => f.FileId === fileId);
|
|
130
|
+
if (!file) {
|
|
131
|
+
return errorResponse(`File ID ${fileId} not found in submission. Available files: ${submission.Files.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
// Check file size before downloading
|
|
134
|
+
if (file.Size > MAX_FILE_SIZE) {
|
|
135
|
+
return errorResponse(`File too large (${Math.round(file.Size / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
136
|
+
}
|
|
137
|
+
// D2L file download URL pattern for submission files
|
|
138
|
+
// GET /d2l/api/le/(version)/(orgUnitId)/dropbox/folders/(folderId)/submissions/(submissionId)/files/(fileId)/download
|
|
139
|
+
const downloadApiPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/${submission.Id}/files/${fileId}/download`);
|
|
140
|
+
// Fetch file
|
|
141
|
+
const response = await apiClient.getRaw(downloadApiPath);
|
|
142
|
+
// Download body as buffer
|
|
143
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
144
|
+
// Double-check actual size
|
|
145
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
146
|
+
return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
147
|
+
}
|
|
148
|
+
// Use custom filename if provided, otherwise use original submission filename
|
|
149
|
+
const originalFilename = file.FileName;
|
|
150
|
+
const effectiveFilename = customFilename || file.FileName;
|
|
151
|
+
// Use secureDownload for path traversal prevention, file type validation, and conflict resolution
|
|
152
|
+
const result = await secureDownload({
|
|
153
|
+
targetDir: downloadPath,
|
|
154
|
+
filename: effectiveFilename,
|
|
155
|
+
data: buffer,
|
|
156
|
+
});
|
|
157
|
+
log("INFO", `Submission file downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
158
|
+
return toolResponse({
|
|
159
|
+
success: true,
|
|
160
|
+
filePath: result.path,
|
|
161
|
+
fileSize: result.size,
|
|
162
|
+
mimeType: result.mime,
|
|
163
|
+
originalFilename,
|
|
164
|
+
message: `File downloaded successfully to ${result.path}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=download-file.js.map
|