brightspace-mcp-server 5.1.25 → 5.1.27
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 +2 -0
- package/build/auth/auth-runner.js +31 -1
- package/build/server.js +2 -1
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +4 -0
- package/build/tools/sign-in.js +108 -0
- package/build/tools/sign-out.js +1 -5
- package/build/tools/tool-helpers.js +9 -0
- package/build/utils/zip-extract.js +38 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,8 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
159
159
|
|
|
160
160
|
**Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
|
|
161
161
|
|
|
162
|
+
**To sign in,** just ask your AI app to sign you in to Brightspace — it can start the sign-in itself now, and it opens a window for you when no password is saved. You can also run `npx -y brightspace-mcp-server@latest auth` in a terminal.
|
|
163
|
+
|
|
162
164
|
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Both also cancel a sign-in an AI app started that is still waiting for you to approve it (`auth --logout` can't on Windows, and leaves an `auth` run in another terminal alone, deleting nothing). Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
|
|
163
165
|
|
|
164
166
|
How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
|
|
@@ -422,6 +422,15 @@ export class AuthRunner {
|
|
|
422
422
|
timeoutMs;
|
|
423
423
|
onProgress;
|
|
424
424
|
sessionDir;
|
|
425
|
+
/**
|
|
426
|
+
* Whether the next child this runner spawns should open a window, set by
|
|
427
|
+
* sign_in and cleared as that child starts. A first sign-in with no saved
|
|
428
|
+
* password cannot finish headlessly: there is nothing to type into the
|
|
429
|
+
* school's form, so it waits and times out. Opt-in and one-shot, because
|
|
430
|
+
* background recovery must never put a browser over the user's work on its
|
|
431
|
+
* own; that is the standing rule the D2L_HEADLESS default encodes.
|
|
432
|
+
*/
|
|
433
|
+
visibleNextChild = false;
|
|
425
434
|
constructor(options = {}) {
|
|
426
435
|
// Resolve paths relative to this file's compiled location (build/auth/auth-runner.js)
|
|
427
436
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -440,6 +449,15 @@ export class AuthRunner {
|
|
|
440
449
|
busy() {
|
|
441
450
|
return this.inFlight !== null || this.childDone !== null;
|
|
442
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Ask that the next child this runner spawns sign in with a visible window
|
|
454
|
+
* (see visibleNextChild). Takes effect only for a child this runner starts:
|
|
455
|
+
* a call that joins a sign-in already running inherits that sign-in's mode,
|
|
456
|
+
* so sign_in checks busy() before asking.
|
|
457
|
+
*/
|
|
458
|
+
requestVisibleBrowser() {
|
|
459
|
+
this.visibleNextChild = true;
|
|
460
|
+
}
|
|
443
461
|
/**
|
|
444
462
|
* Cancel this process's sign-in, if one is running: stop the child at once
|
|
445
463
|
* (no MFA cooldown is recorded, see forceStop) and answer every call waiting
|
|
@@ -811,6 +829,10 @@ export class AuthRunner {
|
|
|
811
829
|
*/
|
|
812
830
|
async spawnAuth() {
|
|
813
831
|
const started = Date.now();
|
|
832
|
+
// Read and cleared here, so a visible request never outlives the one
|
|
833
|
+
// child it was made for.
|
|
834
|
+
const visible = this.visibleNextChild;
|
|
835
|
+
this.visibleNextChild = false;
|
|
814
836
|
const recoveryId = newRecoveryId();
|
|
815
837
|
devActivity("recovery_started", { recoveryId });
|
|
816
838
|
log("INFO", "Auto-launching brightspace-auth...");
|
|
@@ -822,7 +844,15 @@ export class AuthRunner {
|
|
|
822
844
|
// unless D2L_HEADLESS explicitly asks for one; the saved
|
|
823
845
|
// visible-browser preference applies to manual sign-in only.
|
|
824
846
|
// The recovery id lets the child's dev activity join this recovery.
|
|
825
|
-
|
|
847
|
+
// An explicit sign_in request for a window comes last, so it wins
|
|
848
|
+
// over an inherited D2L_HEADLESS that would hide the one page the
|
|
849
|
+
// user has to finish by hand.
|
|
850
|
+
env: {
|
|
851
|
+
D2L_HEADLESS: "true",
|
|
852
|
+
...process.env,
|
|
853
|
+
[RECOVERY_ID_ENV]: recoveryId,
|
|
854
|
+
...(visible ? { D2L_HEADLESS: "false" } : {}),
|
|
855
|
+
},
|
|
826
856
|
stdio: ["ignore", "pipe", "pipe"],
|
|
827
857
|
detached: process.platform !== "win32",
|
|
828
858
|
windowsHide: true,
|
package/build/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
|
|
|
9
9
|
import { trackToolActivity } from "./utils/dev-activity.js";
|
|
10
10
|
import { log } from "./utils/logger.js";
|
|
11
11
|
import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
|
|
12
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
12
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
13
13
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
14
14
|
/**
|
|
15
15
|
* Build the shared runtime. Nothing here reaches Brightspace: API versions
|
|
@@ -94,6 +94,7 @@ export function createMcpServer(runtime) {
|
|
|
94
94
|
registerGetServerInfo(server, config, version, apiClient);
|
|
95
95
|
registerSearchCourse(server, apiClient);
|
|
96
96
|
registerSignOut(server, config, apiClient, tokenManager, authRunner);
|
|
97
|
+
registerSignIn(server, config, tokenManager, authRunner);
|
|
97
98
|
// Instructor / TA dropbox tools (read-only) — 403 for a student account.
|
|
98
99
|
registerGetDropboxFolders(server, apiClient);
|
|
99
100
|
registerGetDropboxSubmissions(server, apiClient);
|
package/build/tools/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { registerGetVideoTranscript } from "./get-video-transcript.js";
|
|
|
24
24
|
export { registerGetServerInfo } from "./get-server-info.js";
|
|
25
25
|
export { registerSearchCourse } from "./search-course.js";
|
|
26
26
|
export { registerSignOut } from "./sign-out.js";
|
|
27
|
+
export { registerSignIn } from "./sign-in.js";
|
|
27
28
|
// Instructor / TA dropbox tools (read-only)
|
|
28
29
|
export { registerGetDropboxFolders } from "./get-dropbox-folders.js";
|
|
29
30
|
export { registerGetDropboxSubmissions } from "./get-dropbox-submissions.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -162,6 +162,10 @@ export const GetMyGroupsSchema = z.object({
|
|
|
162
162
|
});
|
|
163
163
|
export const GetServerInfoSchema = z.object({});
|
|
164
164
|
export const SignOutSchema = z.object({});
|
|
165
|
+
export const SignInSchema = z.object({
|
|
166
|
+
visible: z.boolean().optional()
|
|
167
|
+
.describe("Open a browser window for the user to sign in by hand. Omit to decide automatically: a window opens only when no password is saved on this computer, because a sign-in with no window has nothing to type into the school's form and would only time out. Pass true when a background sign-in has already failed or the school's page needs a step it cannot do, false to insist on a background sign-in."),
|
|
168
|
+
});
|
|
165
169
|
// ── Instructor / TA dropbox tools ──────────────────────────────────────────
|
|
166
170
|
// Read-only, instructor/TA-side views of a course's dropbox (assignment)
|
|
167
171
|
// folders and the submissions inside them. These answer "who has/hasn't
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { AuthProcessError } from "../auth/auth-runner.js";
|
|
7
|
+
import { log } from "../utils/logger.js";
|
|
8
|
+
import { createSSOFlow } from "../auth/sso-flow.js";
|
|
9
|
+
import { SignInSchema } from "./schemas.js";
|
|
10
|
+
import { authPendingNotice, sanitizeError, textResponse as text } from "./tool-helpers.js";
|
|
11
|
+
import { AUTH_COMMAND, SETUP_COMMAND } from "../utils/commands.js";
|
|
12
|
+
const ALREADY = "Already signed in to Brightspace on this computer; the saved session is still valid. Nothing was changed. Go ahead and call the tool you actually need.";
|
|
13
|
+
const ENV_CREDENTIAL = "Nothing to do. This server authenticates with a credential from its configuration (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE), not with a sign-in it can start. If Brightspace is rejecting it, the user has to paste a fresh one into their AI app's configuration or .env file and restart the app.";
|
|
14
|
+
const NO_RUNNER = `This server cannot start a sign-in itself. Tell the user to run \`${AUTH_COMMAND}\` in a terminal, prefixed with D2L_HEADLESS=false if they need to see the sign-in page and finish it by hand.`;
|
|
15
|
+
const SUCCESS = "Signed in to Brightspace. The session is saved on this computer, so other Brightspace apps here can use it too.";
|
|
16
|
+
/** What an open window means for the caller: this one waits on a person, not on a retry. */
|
|
17
|
+
const WINDOW_OPEN = "A browser window is opening on the user's screen for them to sign in by hand. Tell the user to complete it, including any 2FA, then ask them to say when they are done.";
|
|
18
|
+
const NEEDS_PASSWORD_FOR_HEADLESS = `No password is saved on this computer, so a sign-in with no window has nothing to type into the school's form and would only wait and time out. Either call sign_in again with visible true, or tell the user to run \`${SETUP_COMMAND}\` to save one.`;
|
|
19
|
+
/**
|
|
20
|
+
* A sign-in still working is progress, not a failure, so these are not marked
|
|
21
|
+
* isError: the shared guidance for them already tells the caller to come back
|
|
22
|
+
* (each call polls the running sign-in), and isError invites a model to give
|
|
23
|
+
* up or apologize instead.
|
|
24
|
+
*/
|
|
25
|
+
const STILL_WORKING = new Set([
|
|
26
|
+
"mfaPending",
|
|
27
|
+
"automaticPending",
|
|
28
|
+
"inProgress",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Register sign_in tool.
|
|
32
|
+
*
|
|
33
|
+
* The counterpart to sign_out, and the reason it exists: an AI app had no way
|
|
34
|
+
* to *start* a sign-in. Recovery happened only as a side effect of some other
|
|
35
|
+
* tool hitting a 401, and the one documented fix for a first sign-in — open a
|
|
36
|
+
* window — lived in an environment variable that nothing reachable from a
|
|
37
|
+
* chat could set. So an assistant whose session had lapsed could only tell
|
|
38
|
+
* the user to go and run a terminal command.
|
|
39
|
+
*
|
|
40
|
+
* What is specific to this tool is the decision to sign in at all, and the
|
|
41
|
+
* window: the saved session is read first (TokenManager), so calling this
|
|
42
|
+
* speculatively costs the user no MFA approval, and `visible`, left out,
|
|
43
|
+
* opens a window exactly when the school's sign-in flow has nothing to sign
|
|
44
|
+
* in with (no username, or no password and not passwordless), which is when a
|
|
45
|
+
* headless sign-in can only time out.
|
|
46
|
+
* A window cannot be asked of a sign-in already running, which keeps its own
|
|
47
|
+
* mode, so busy() is checked before one is promised.
|
|
48
|
+
*
|
|
49
|
+
* Everything a *failure* should say is authPendingNotice's, not this file's:
|
|
50
|
+
* the per-kind guidance, the MFA number, the dedup when a parallel call
|
|
51
|
+
* already carried it, and the "ask for 2FA less often" tip. Writing that
|
|
52
|
+
* wording again here would be a second policy to keep in step with
|
|
53
|
+
* AuthRunner — and a wrong one, since the house guidance tells a caller to
|
|
54
|
+
* keep calling while a sign-in polls, which is the opposite of what a
|
|
55
|
+
* hand-written "do not retry" would say.
|
|
56
|
+
*/
|
|
57
|
+
export function registerSignIn(server, config, tokenManager, authRunner) {
|
|
58
|
+
server.registerTool("sign_in", {
|
|
59
|
+
title: "Sign In to Brightspace",
|
|
60
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
61
|
+
description: "Sign in to Brightspace on this computer so the other tools work. Does nothing and says so if the saved session is already valid, so it is safe to call first when you are unsure. Opens a browser window for the user to sign in by hand when no password is saved (nothing to type means a windowless sign-in can only time out); otherwise it signs in in the background and reports the 2FA number for the user to approve on their phone. Call this when a Brightspace tool failed because of authentication, or when the user asks to sign in or log in to Brightspace. Every answer says what to do next, including whether to call again.",
|
|
62
|
+
inputSchema: SignInSchema,
|
|
63
|
+
}, async ({ visible }) => {
|
|
64
|
+
if (config.envAccessToken || config.envSessionCookie)
|
|
65
|
+
return text(ENV_CREDENTIAL);
|
|
66
|
+
if (!authRunner)
|
|
67
|
+
return text(NO_RUNNER);
|
|
68
|
+
// First, so signing in again when the saved session is fine never asks
|
|
69
|
+
// the user for an approval they did not need to give.
|
|
70
|
+
try {
|
|
71
|
+
const token = await tokenManager.getToken();
|
|
72
|
+
if (token && tokenManager.isValid(token))
|
|
73
|
+
return text(ALREADY);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// A session that cannot be read is exactly what signing in fixes.
|
|
77
|
+
log("DEBUG", "sign_in could not read the saved session; signing in", error);
|
|
78
|
+
}
|
|
79
|
+
// The same test the sign-in itself makes before typing anything: each
|
|
80
|
+
// school's flow decides. A passwordless account keeps no password yet
|
|
81
|
+
// signs in without a window (the phone approves it).
|
|
82
|
+
const canSignInHeadless = createSSOFlow(config).hasCredentials();
|
|
83
|
+
// A window only when a headless sign-in could not work anyway.
|
|
84
|
+
const wantWindow = visible ?? !canSignInHeadless;
|
|
85
|
+
// A joined sign-in keeps the mode it started with, so asking would only
|
|
86
|
+
// promise the user a window that never appears.
|
|
87
|
+
const windowOpened = wantWindow && !authRunner.busy();
|
|
88
|
+
if (windowOpened)
|
|
89
|
+
authRunner.requestVisibleBrowser();
|
|
90
|
+
else if (wantWindow)
|
|
91
|
+
log("INFO", "sign_in was asked for a window but a sign-in is already running");
|
|
92
|
+
if (!wantWindow && !canSignInHeadless)
|
|
93
|
+
return text(NEEDS_PASSWORD_FOR_HEADLESS, true);
|
|
94
|
+
try {
|
|
95
|
+
await authRunner.run();
|
|
96
|
+
return text(SUCCESS);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof AuthProcessError) {
|
|
100
|
+
const guidance = authPendingNotice(error);
|
|
101
|
+
return text(windowOpened ? `${WINDOW_OPEN} ${guidance}` : guidance, !STILL_WORKING.has(error.kind));
|
|
102
|
+
}
|
|
103
|
+
// Everything else goes through the house path, which logs it and
|
|
104
|
+
// keeps stack traces and raw responses out of the answer.
|
|
105
|
+
return sanitizeError(error);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
package/build/tools/sign-out.js
CHANGED
|
@@ -9,7 +9,7 @@ import { clearSavedSession, RemoveError } from "../auth/logout.js";
|
|
|
9
9
|
import { authLockPath } from "../auth/mfa-challenge.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { SignOutSchema } from "./schemas.js";
|
|
12
|
-
import {
|
|
12
|
+
import { textResponse as text } from "./tool-helpers.js";
|
|
13
13
|
const KEPT = "Kept (if you set them up): your saved password, your settings, and your authenticator key.";
|
|
14
14
|
const OTHER_APPS = "Other Brightspace apps open on this computer are signed out too and will sign in again from scratch on their next question. An app you opened before updating to this version may keep answering for up to an hour; quit and reopen it to sign it out right away.";
|
|
15
15
|
const NEXT_SIGN_IN = "Because your saved password (and authenticator key, if you saved one) stay on this computer, that next sign-in may just ask you to approve it, or finish on its own if you saved an authenticator key. On a shared computer, also delete the saved \"brightspace-mcp-server\" entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows).";
|
|
@@ -20,10 +20,6 @@ const NEXT_QUESTION = "Microsoft keeps that prompt open for up to 5 minutes afte
|
|
|
20
20
|
const ALREADY_SIGNED_OUT = "You're already signed out of Brightspace on this computer; there was no saved session to remove.";
|
|
21
21
|
const NOTHING_SAVED = "There was no saved session to remove.";
|
|
22
22
|
const UNEXPECTED_FAILURE = "Could not clear the saved session because of an unexpected error. Nothing more was changed; try again, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal to see the details.";
|
|
23
|
-
function text(message, isError = false) {
|
|
24
|
-
const result = withUpdateNotice([{ type: "text", text: message }]);
|
|
25
|
-
return isError ? { ...result, isError: true } : result;
|
|
26
|
-
}
|
|
27
23
|
/**
|
|
28
24
|
* Register sign_out tool.
|
|
29
25
|
*
|
|
@@ -29,6 +29,15 @@ export function withUpdateNotice(content) {
|
|
|
29
29
|
content.push({ type: "text", text: notice });
|
|
30
30
|
return { content };
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A plain-text answer, as the sign-in tools give: prose a model relays to the
|
|
34
|
+
* user, not a JSON envelope to read fields out of. Carries the update notice
|
|
35
|
+
* like every other response.
|
|
36
|
+
*/
|
|
37
|
+
export function textResponse(message, isError = false) {
|
|
38
|
+
const result = withUpdateNotice([{ type: "text", text: message }]);
|
|
39
|
+
return isError ? { ...result, isError: true } : result;
|
|
40
|
+
}
|
|
32
41
|
/**
|
|
33
42
|
* Wrap data as MCP-compatible tool result.
|
|
34
43
|
*
|
|
@@ -16,8 +16,9 @@ import { inflateRawSync } from "node:zlib";
|
|
|
16
16
|
* parsing a header that does not exist.
|
|
17
17
|
*
|
|
18
18
|
* Nothing here throws. A truncated download, an unsupported compression
|
|
19
|
-
* method,
|
|
20
|
-
* is simply to report the file
|
|
19
|
+
* method, a ZIP64 archive, or an entry that inflates past the size budget all
|
|
20
|
+
* return null, because the caller's fallback is simply to report the file
|
|
21
|
+
* without its text.
|
|
21
22
|
*/
|
|
22
23
|
const EOCD_SIGNATURE = 0x06054b50;
|
|
23
24
|
const CENTRAL_FILE_SIGNATURE = 0x02014b50;
|
|
@@ -26,6 +27,13 @@ const EOCD_MAX_COMMENT = 0xffff;
|
|
|
26
27
|
const ZIP64_MARKER = 0xffffffff;
|
|
27
28
|
const METHOD_STORED = 0;
|
|
28
29
|
const METHOD_DEFLATE = 8;
|
|
30
|
+
/**
|
|
31
|
+
* Most bytes one document may inflate to, across every entry read from it.
|
|
32
|
+
* DEFLATE reaches ~1000:1, so a 50 MB download could otherwise inflate to
|
|
33
|
+
* tens of gigabytes and take the server down (issue #380). Real Office XML
|
|
34
|
+
* sits far below this.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
|
|
29
37
|
/** Find the End of Central Directory record by scanning backwards. */
|
|
30
38
|
function findEocd(buffer) {
|
|
31
39
|
const earliest = Math.max(0, buffer.length - EOCD_MIN_SIZE - EOCD_MAX_COMMENT);
|
|
@@ -70,7 +78,13 @@ function readCentralDirectory(buffer) {
|
|
|
70
78
|
}
|
|
71
79
|
return entries;
|
|
72
80
|
}
|
|
73
|
-
|
|
81
|
+
/**
|
|
82
|
+
* An entry's bytes, or null when it would exceed `limit`. The declared size
|
|
83
|
+
* is checked first, but a header can lie, so inflation is capped as well.
|
|
84
|
+
*/
|
|
85
|
+
function readEntryData(buffer, entry, limit) {
|
|
86
|
+
if (entry.uncompressedSize > limit)
|
|
87
|
+
return null;
|
|
74
88
|
const header = entry.localHeaderOffset;
|
|
75
89
|
if (header + 30 > buffer.length)
|
|
76
90
|
return null;
|
|
@@ -84,18 +98,19 @@ function readEntryData(buffer, entry) {
|
|
|
84
98
|
return null;
|
|
85
99
|
const raw = buffer.subarray(start, end);
|
|
86
100
|
if (entry.method === METHOD_STORED)
|
|
87
|
-
return Buffer.from(raw);
|
|
101
|
+
return raw.length > limit ? null : Buffer.from(raw);
|
|
88
102
|
if (entry.method !== METHOD_DEFLATE)
|
|
89
103
|
return null;
|
|
90
104
|
try {
|
|
91
|
-
|
|
105
|
+
// Throws a RangeError past maxOutputLength.
|
|
106
|
+
return inflateRawSync(raw, { maxOutputLength: Math.max(1, limit) });
|
|
92
107
|
}
|
|
93
108
|
catch {
|
|
94
109
|
return null;
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
/** One entry from a ZIP archive by exact name, or null. Never throws. */
|
|
98
|
-
export function extractZipEntry(buffer, entryName) {
|
|
113
|
+
export function extractZipEntry(buffer, entryName, limit = MAX_INFLATED_BYTES) {
|
|
99
114
|
try {
|
|
100
115
|
const entries = readCentralDirectory(buffer);
|
|
101
116
|
if (!entries)
|
|
@@ -103,7 +118,7 @@ export function extractZipEntry(buffer, entryName) {
|
|
|
103
118
|
const entry = entries.find((e) => e.name === entryName);
|
|
104
119
|
if (!entry)
|
|
105
120
|
return null;
|
|
106
|
-
return readEntryData(buffer, entry);
|
|
121
|
+
return readEntryData(buffer, entry, limit);
|
|
107
122
|
}
|
|
108
123
|
catch {
|
|
109
124
|
return null;
|
|
@@ -145,7 +160,22 @@ export function officeDocumentText(buffer) {
|
|
|
145
160
|
const names = listZipEntries(buffer);
|
|
146
161
|
if (!names)
|
|
147
162
|
return null;
|
|
148
|
-
|
|
163
|
+
// One budget for the whole document, so a PPTX can't spread a zip bomb
|
|
164
|
+
// across many slides that each fit on their own. An entry that fails to
|
|
165
|
+
// read (oversize included) ends the walk: retrying the rest would re-inflate
|
|
166
|
+
// up to the budget once per entry.
|
|
167
|
+
let budget = MAX_INFLATED_BYTES;
|
|
168
|
+
const read = (name) => {
|
|
169
|
+
if (budget <= 0)
|
|
170
|
+
return "";
|
|
171
|
+
const data = extractZipEntry(buffer, name, budget);
|
|
172
|
+
if (!data) {
|
|
173
|
+
budget = 0;
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
budget -= data.length;
|
|
177
|
+
return xmlToText(data.toString("utf-8"));
|
|
178
|
+
};
|
|
149
179
|
let text = "";
|
|
150
180
|
if (names.includes("word/document.xml")) {
|
|
151
181
|
text = read("word/document.xml");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.27",
|
|
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",
|