brightspace-mcp-server 3.9.2 → 3.9.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 +16 -1
- package/build/auth/browser-auth.js +55 -27
- package/build/auth/session-store.js +24 -0
- package/build/auth/token-manager.js +5 -0
- package/build/index.js +11 -1
- package/build/prompts/course-summary.js +41 -0
- package/build/prompts/grade-audit.js +48 -0
- package/build/prompts/index.js +10 -0
- package/build/prompts/study-planner.js +52 -0
- package/build/prompts/weekly-briefing.js +41 -0
- package/build/tools/get-assignments.js +4 -0
- package/build/tools/get-server-info.js +49 -4
- package/build/tools/get-upcoming-due-dates.js +5 -0
- package/build/utils/due-in.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,10 +174,25 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
|
|
|
174
174
|
| Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
|
|
175
175
|
| Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
|
|
176
176
|
| Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
|
|
177
|
-
| Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, what Microsoft remembered (`microsoftSession`: stay-signed-in and its expiry, plus whether "Don't ask again" was ticked, already on, not offered, or left off because `D2L_REMEMBER_MFA` isn't set), and `requests` (lightweight counters: responses by status class, network errors, cache hits/misses, coalesced in-flight joins, and token refreshes), without contacting Brightspace or revealing secrets |
|
|
177
|
+
| Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" · "What timezone are my due dates being computed in?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, the server's local timezone and UTC offset (`localTimezone`, `utcOffsetMinutes`), who the session is signed in as when known (`signedInAs: { uniqueName, displayName }`), what Microsoft remembered (`microsoftSession`: stay-signed-in and its expiry, plus whether "Don't ask again" was ticked, already on, not offered, or left off because `D2L_REMEMBER_MFA` isn't set), and `requests` (lightweight counters: responses by status class, network errors, cache hits/misses, coalesced in-flight joins, and token refreshes), without contacting Brightspace or revealing secrets |
|
|
178
178
|
| Calendar | "When is my midterm?" · "What's on my calendar this week?" · "Is lab cancelled on Thursday?" — reads exams, labs, review sessions, and deadlines instructors put only on the course calendar |
|
|
179
179
|
| Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, graded discussion topics (any topic with a due date), and course calendar events such as exams and labs |
|
|
180
180
|
|
|
181
|
+
### Prompts
|
|
182
|
+
|
|
183
|
+
Clients that show server-provided prompts (e.g. Claude Desktop's prompt picker) can also start from one of
|
|
184
|
+
four canned prompts instead of typing a question from scratch:
|
|
185
|
+
|
|
186
|
+
| Prompt | Arguments | What it asks for |
|
|
187
|
+
|--------|-----------|-------------------|
|
|
188
|
+
| `weekly_briefing` | — | A 7-day rollup of what's due, what's new, and what's changed in grades, across every course |
|
|
189
|
+
| `grade_audit` | `courseId` (optional) | Analyzes grades for one course, or all of them, and flags missing or low-scoring items |
|
|
190
|
+
| `study_planner` | `daysAhead` (optional, default 7) | Plans study time from upcoming due dates and calendar events |
|
|
191
|
+
| `course_summary` | `courseId` (required) | Syllabus, content outline, assignments, and grades for one course |
|
|
192
|
+
|
|
193
|
+
Each prompt is a single starter message built from the tools above — it doesn't add any new capability on
|
|
194
|
+
its own, just a one-click way to ask for a common combination of them.
|
|
195
|
+
|
|
181
196
|
`get_course_content` reports `isAvailable`, `availabilityStatus` (`available`, `not_yet_open`, `ended`, `hidden`, or `locked`), `availabilityMessage`, `startDate`, and `endDate` for modules and topics. The effective release window includes restrictions inherited from enclosing modules.
|
|
182
197
|
|
|
183
198
|
When a content-file download returns 403 or 404, `download_file` checks topic metadata and the course table of contents. Confirmed restrictions return `{ success: false, available: false, reason, message, startDate, endDate }` so the assistant can explain when content opens or why it has closed. Unexplained 404s and server/network failures retain their original errors.
|
|
@@ -147,7 +147,13 @@ export class BrowserAuth {
|
|
|
147
147
|
if (material.cookieHeader && material.csrfToken) {
|
|
148
148
|
const minted = await mintAccessToken({ baseUrl: this.config.baseUrl, cookieHeader: material.cookieHeader, csrfToken: material.csrfToken });
|
|
149
149
|
if (minted.ok) {
|
|
150
|
-
|
|
150
|
+
// The mint endpoint itself carries no identity. Reuse the same
|
|
151
|
+
// whoami read the other extraction strategies already validate
|
|
152
|
+
// against so a mint-path login reports signedInAs too; a failure
|
|
153
|
+
// here (offline, slow tenant) must not fail a login that already
|
|
154
|
+
// has a good token, so it degrades to "no identity" instead.
|
|
155
|
+
const identity = await this.validateToken(minted.accessToken).catch(() => null);
|
|
156
|
+
token = { accessToken: minted.accessToken, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser", ...identity };
|
|
151
157
|
}
|
|
152
158
|
else if (minted.reason === "transport") {
|
|
153
159
|
throw new BrowserAuthTransportError(`Token mint temporarily failed: ${minted.detail ?? "network unavailable"}. Saved sign-in state is preserved.`);
|
|
@@ -157,8 +163,11 @@ export class BrowserAuth {
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
token ??= await this.tryExtractToken(page, context);
|
|
160
|
-
if (!token && captured
|
|
161
|
-
|
|
166
|
+
if (!token && captured) {
|
|
167
|
+
const identity = await this.validateToken(captured);
|
|
168
|
+
if (identity) {
|
|
169
|
+
token = { accessToken: captured, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser", ...identity };
|
|
170
|
+
}
|
|
162
171
|
}
|
|
163
172
|
if (!token)
|
|
164
173
|
throw new BrowserAuthError("Brightspace did not provide a usable API token. Saved SSO cookies have been preserved.", "token_extraction");
|
|
@@ -211,31 +220,37 @@ export class BrowserAuth {
|
|
|
211
220
|
* Returns null if every strategy fails.
|
|
212
221
|
*/
|
|
213
222
|
async tryExtractToken(page, context) {
|
|
214
|
-
const build = (token) => {
|
|
223
|
+
const build = (token, identity) => {
|
|
215
224
|
const now = Date.now();
|
|
216
225
|
return {
|
|
217
226
|
accessToken: token,
|
|
218
227
|
capturedAt: now,
|
|
219
228
|
expiresAt: now + this.config.tokenTtl * 1000,
|
|
220
229
|
source: "browser",
|
|
230
|
+
...identity,
|
|
221
231
|
};
|
|
222
232
|
};
|
|
223
233
|
// Strategy 0: localStorage (D2L.Fetch.Tokens) : fastest
|
|
224
234
|
const lsToken = await this.extractLocalStorageToken(page);
|
|
225
|
-
if (lsToken
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
if (lsToken) {
|
|
236
|
+
const identity = await this.validateToken(lsToken);
|
|
237
|
+
if (identity) {
|
|
238
|
+
log("INFO", "Extracted valid Bearer token from localStorage");
|
|
239
|
+
return build(lsToken, identity);
|
|
240
|
+
}
|
|
230
241
|
log("WARN", "localStorage Bearer token failed validation, trying next strategy");
|
|
242
|
+
}
|
|
231
243
|
// Strategy 1: Force a Bearer fetch by hitting the API, then re-check localStorage
|
|
232
244
|
try {
|
|
233
245
|
log("DEBUG", "Navigating to API endpoint to trigger token capture");
|
|
234
246
|
await page.goto(`${this.config.baseUrl}/d2l/api/lp/1.57/users/whoami`, { waitUntil: "load", timeout: 15000 });
|
|
235
247
|
const lsToken2 = await this.extractLocalStorageToken(page);
|
|
236
|
-
if (lsToken2
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
if (lsToken2) {
|
|
249
|
+
const identity = await this.validateToken(lsToken2);
|
|
250
|
+
if (identity) {
|
|
251
|
+
log("INFO", "Extracted valid Bearer token from localStorage after API nudge");
|
|
252
|
+
return build(lsToken2, identity);
|
|
253
|
+
}
|
|
239
254
|
}
|
|
240
255
|
}
|
|
241
256
|
catch (error) {
|
|
@@ -245,25 +260,31 @@ export class BrowserAuth {
|
|
|
245
260
|
}
|
|
246
261
|
// Strategy 2: XSRF / page JS context
|
|
247
262
|
const xsrfToken = await this.extractXsrfToken(page);
|
|
248
|
-
if (xsrfToken
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
if (xsrfToken) {
|
|
264
|
+
const identity = await this.validateToken(xsrfToken);
|
|
265
|
+
if (identity) {
|
|
266
|
+
log("INFO", "Extracted valid XSRF token from page context");
|
|
267
|
+
return build(xsrfToken, identity);
|
|
268
|
+
}
|
|
253
269
|
log("WARN", "XSRF token failed validation, trying next strategy");
|
|
270
|
+
}
|
|
254
271
|
// Strategy 3: Cookie-based auth
|
|
255
272
|
const cookieToken = await this.extractCookieToken(context);
|
|
256
|
-
if (cookieToken
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
273
|
+
if (cookieToken) {
|
|
274
|
+
const identity = await this.validateToken(cookieToken);
|
|
275
|
+
if (identity) {
|
|
276
|
+
log("INFO", "Extracted valid session cookie for API auth");
|
|
277
|
+
return build(cookieToken, identity);
|
|
278
|
+
}
|
|
261
279
|
log("WARN", "Cookie token failed validation");
|
|
280
|
+
}
|
|
262
281
|
return null;
|
|
263
282
|
}
|
|
264
283
|
/**
|
|
265
284
|
* Validate a token by making a test API call to /users/whoami.
|
|
266
|
-
* Returns
|
|
285
|
+
* Returns the non-secret identity whoami reported when the token is
|
|
286
|
+
* accepted by D2L (an empty object if the response carried no usable
|
|
287
|
+
* name fields), or null when the token is rejected.
|
|
267
288
|
*/
|
|
268
289
|
async validateToken(token) {
|
|
269
290
|
let response;
|
|
@@ -279,7 +300,7 @@ export class BrowserAuth {
|
|
|
279
300
|
if (response.ok) {
|
|
280
301
|
const contentType = response.headers.get("content-type") ?? "";
|
|
281
302
|
if (!contentType.includes("json"))
|
|
282
|
-
return
|
|
303
|
+
return null;
|
|
283
304
|
let user;
|
|
284
305
|
try {
|
|
285
306
|
user = await response.json();
|
|
@@ -287,11 +308,18 @@ export class BrowserAuth {
|
|
|
287
308
|
catch (error) {
|
|
288
309
|
throw new BrowserAuthTransportError("Token validation returned unreadable JSON.", { cause: error });
|
|
289
310
|
}
|
|
290
|
-
const
|
|
291
|
-
|
|
311
|
+
const whoami = user;
|
|
312
|
+
const identifier = whoami?.Identifier;
|
|
313
|
+
const valid = (typeof identifier === "string" && identifier.length > 0) || (typeof identifier === "number" && Number.isFinite(identifier));
|
|
314
|
+
if (!valid)
|
|
315
|
+
return null;
|
|
316
|
+
return {
|
|
317
|
+
...(typeof whoami?.UniqueName === "string" && whoami.UniqueName ? { uniqueName: whoami.UniqueName } : {}),
|
|
318
|
+
...(typeof whoami?.DisplayName === "string" && whoami.DisplayName ? { displayName: whoami.DisplayName } : {}),
|
|
319
|
+
};
|
|
292
320
|
}
|
|
293
321
|
if (response.status === 401 || response.status === 403)
|
|
294
|
-
return
|
|
322
|
+
return null;
|
|
295
323
|
throw new BrowserAuthTransportError(`Token validation temporarily failed with HTTP ${response.status}.`);
|
|
296
324
|
}
|
|
297
325
|
/** Restore first, and only attempt credentials after a real sign-in is needed. */
|
|
@@ -31,6 +31,8 @@ function validToken(value) {
|
|
|
31
31
|
&& (token.source === "browser" || token.source === "cache")
|
|
32
32
|
&& (token.cookieHeader === undefined || typeof token.cookieHeader === "string")
|
|
33
33
|
&& (token.csrfToken === undefined || typeof token.csrfToken === "string")
|
|
34
|
+
&& (token.uniqueName === undefined || typeof token.uniqueName === "string")
|
|
35
|
+
&& (token.displayName === undefined || typeof token.displayName === "string")
|
|
34
36
|
&& validTenantOrigin(token.tenantOrigin);
|
|
35
37
|
}
|
|
36
38
|
function equalToken(a, b) {
|
|
@@ -174,6 +176,28 @@ export class SessionStore {
|
|
|
174
176
|
this.storeError("load", error);
|
|
175
177
|
}
|
|
176
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Read-only sibling of load(): same decode (legacy-upgrade validation and
|
|
181
|
+
* native-key decrypt included), but never takes the write lock and never
|
|
182
|
+
* writes back a migrated version 1 record. load() upgrades a legacy file
|
|
183
|
+
* in place as a side effect of reading it; callers that just want to know
|
|
184
|
+
* "what identity/token is on disk right now" (get_server_info's
|
|
185
|
+
* signedInAs, say) should not silently rewrite session.json or contend
|
|
186
|
+
* with an in-flight sign-in for the write lock just to answer that.
|
|
187
|
+
*/
|
|
188
|
+
async peek() {
|
|
189
|
+
try {
|
|
190
|
+
const record = await this.readFile();
|
|
191
|
+
if (!record)
|
|
192
|
+
return null;
|
|
193
|
+
if (record.version === 1)
|
|
194
|
+
await this.assertLegacyUpgradePending();
|
|
195
|
+
return await this.decode(record);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.storeError("load", error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
177
201
|
async clear() {
|
|
178
202
|
try {
|
|
179
203
|
await this.withWriteLock(async () => {
|
|
@@ -171,6 +171,11 @@ export class TokenManager {
|
|
|
171
171
|
source: "browser",
|
|
172
172
|
cookieHeader: stale.cookieHeader,
|
|
173
173
|
csrfToken: stale.csrfToken,
|
|
174
|
+
// The cookie mint only returns a JWT, not an identity; carry the
|
|
175
|
+
// uniqueName/displayName the original browser login captured
|
|
176
|
+
// forward so an hourly HTTP re-mint doesn't erase signedInAs.
|
|
177
|
+
uniqueName: stale.uniqueName,
|
|
178
|
+
displayName: stale.displayName,
|
|
174
179
|
};
|
|
175
180
|
if (!await this.sessionStore.saveIfCurrent(token, stale))
|
|
176
181
|
return this.afterConcurrentChange();
|
package/build/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { readFileSync } from "node:fs";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
19
19
|
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
|
|
20
|
+
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
20
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
22
|
const __dirname = dirname(__filename);
|
|
22
23
|
const PKG_VERSION = (() => {
|
|
@@ -132,10 +133,19 @@ else {
|
|
|
132
133
|
registerGetVideoTranscript(server, apiClient);
|
|
133
134
|
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
134
135
|
log("DEBUG", "MCP tools registered (16 tools)");
|
|
136
|
+
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
137
|
+
// picker, distinct from tools. Each one is a canned user message that
|
|
138
|
+
// names our own tools by name, so a client with no idea what to ask
|
|
139
|
+
// for still gets a one-click starting point.
|
|
140
|
+
registerWeeklyBriefingPrompt(server);
|
|
141
|
+
registerGradeAuditPrompt(server);
|
|
142
|
+
registerStudyPlannerPrompt(server);
|
|
143
|
+
registerCourseSummaryPrompt(server);
|
|
144
|
+
log("DEBUG", "MCP prompts registered (4 prompts)");
|
|
135
145
|
// Connect stdio transport
|
|
136
146
|
const transport = new StdioServerTransport();
|
|
137
147
|
await server.connect(transport);
|
|
138
|
-
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (16 tools registered)");
|
|
148
|
+
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (16 tools, 4 prompts registered)");
|
|
139
149
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
140
150
|
}
|
|
141
151
|
catch (error) {
|
|
@@ -0,0 +1,41 @@
|
|
|
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 { z } from "zod";
|
|
7
|
+
/**
|
|
8
|
+
* Register the course_summary prompt.
|
|
9
|
+
*
|
|
10
|
+
* `courseId` is required — unlike the other three prompts this one is
|
|
11
|
+
* always about exactly one course, so there is no sensible "all courses"
|
|
12
|
+
* fallback the way get_my_grades or get_assignments have.
|
|
13
|
+
*/
|
|
14
|
+
export function registerCourseSummaryPrompt(server) {
|
|
15
|
+
server.registerPrompt("course_summary", {
|
|
16
|
+
title: "Course Summary",
|
|
17
|
+
description: "A one-course rollup: syllabus, content outline, assignments, and current grades.",
|
|
18
|
+
argsSchema: {
|
|
19
|
+
courseId: z.coerce
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.positive()
|
|
23
|
+
.describe("Course ID to summarize."),
|
|
24
|
+
},
|
|
25
|
+
}, async (args) => ({
|
|
26
|
+
messages: [
|
|
27
|
+
{
|
|
28
|
+
role: "user",
|
|
29
|
+
content: {
|
|
30
|
+
type: "text",
|
|
31
|
+
text: `Give me a summary of course ${args.courseId}. Call get_syllabus for the course ` +
|
|
32
|
+
`description and policies, get_course_content for the module and topic outline, ` +
|
|
33
|
+
`get_assignments for what's assigned and its due dates and submission status, and ` +
|
|
34
|
+
`get_my_grades for where I currently stand. Pull it together into one summary: what ` +
|
|
35
|
+
"the course covers, how it's structured, what's outstanding, and how my grades look " +
|
|
36
|
+
"so far.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { z } from "zod";
|
|
7
|
+
/**
|
|
8
|
+
* Register the grade_audit prompt.
|
|
9
|
+
*
|
|
10
|
+
* `courseId` is optional, same as the tools it drives (get_my_grades,
|
|
11
|
+
* get_assignments): omitted, it audits every enrolled course at once.
|
|
12
|
+
*/
|
|
13
|
+
export function registerGradeAuditPrompt(server) {
|
|
14
|
+
server.registerPrompt("grade_audit", {
|
|
15
|
+
title: "Grade Audit",
|
|
16
|
+
description: "Analyze grades for one course or all of them, flagging missing, ungraded, or low-scoring items.",
|
|
17
|
+
argsSchema: {
|
|
18
|
+
courseId: z.coerce
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.positive()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Course ID to audit. If omitted, audits every enrolled course."),
|
|
24
|
+
},
|
|
25
|
+
}, async (args) => {
|
|
26
|
+
const scope = args.courseId !== undefined
|
|
27
|
+
? `course ${args.courseId}`
|
|
28
|
+
: "every enrolled course";
|
|
29
|
+
const courseArg = args.courseId !== undefined ? `courseId: ${args.courseId}` : "no courseId (all courses)";
|
|
30
|
+
return {
|
|
31
|
+
messages: [
|
|
32
|
+
{
|
|
33
|
+
role: "user",
|
|
34
|
+
content: {
|
|
35
|
+
type: "text",
|
|
36
|
+
text: `Audit my grades for ${scope}. Call get_my_grades with ${courseArg} and ` +
|
|
37
|
+
`get_assignments with ${courseArg} and cross-reference them: flag any assignment ` +
|
|
38
|
+
"or quiz with no grade posted yet, anything graded well below the class or my own " +
|
|
39
|
+
"average, and any gradebook column from get_my_grades that matches no assignment " +
|
|
40
|
+
"(a gradeOnly item, such as a proctored exam). If no courseId was given, first call " +
|
|
41
|
+
"get_my_courses so you can label every finding with its course name, and summarize " +
|
|
42
|
+
"per course before giving an overall picture.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
// Prompt registration functions - barrel export
|
|
7
|
+
export { registerWeeklyBriefingPrompt } from "./weekly-briefing.js";
|
|
8
|
+
export { registerGradeAuditPrompt } from "./grade-audit.js";
|
|
9
|
+
export { registerStudyPlannerPrompt } from "./study-planner.js";
|
|
10
|
+
export { registerCourseSummaryPrompt } from "./course-summary.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { z } from "zod";
|
|
7
|
+
/**
|
|
8
|
+
* Register the study_planner prompt.
|
|
9
|
+
*
|
|
10
|
+
* `daysAhead` mirrors get_upcoming_due_dates' own argument (same name,
|
|
11
|
+
* same default) so the window the user asks about and the window the
|
|
12
|
+
* underlying tool call uses never drift apart.
|
|
13
|
+
*/
|
|
14
|
+
export function registerStudyPlannerPrompt(server) {
|
|
15
|
+
server.registerPrompt("study_planner", {
|
|
16
|
+
title: "Study Planner",
|
|
17
|
+
description: "Plan study time from upcoming due dates and calendar events over the next N days (default 7).",
|
|
18
|
+
argsSchema: {
|
|
19
|
+
// .optional() (not just .default()) keeps this argument out of the
|
|
20
|
+
// "required" list in prompts/list — a bare .default() still shows up
|
|
21
|
+
// as required there even though a missing value falls back fine.
|
|
22
|
+
daysAhead: z.coerce
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(90)
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Number of days ahead to plan for. Defaults to 7."),
|
|
29
|
+
},
|
|
30
|
+
}, async (args) => {
|
|
31
|
+
// args is undefined when the SDK's no-args path reaches this callback
|
|
32
|
+
// for all-optional schemas; args.daysAhead is undefined when the field
|
|
33
|
+
// itself was omitted. Either way, fall back to 7.
|
|
34
|
+
const days = args?.daysAhead ?? 7;
|
|
35
|
+
return {
|
|
36
|
+
messages: [
|
|
37
|
+
{
|
|
38
|
+
role: "user",
|
|
39
|
+
content: {
|
|
40
|
+
type: "text",
|
|
41
|
+
text: `Build me a study plan for the next ${days} days. Call ` +
|
|
42
|
+
`get_upcoming_due_dates with daysAhead: ${days} for assignments, quizzes, ` +
|
|
43
|
+
`and discussion due dates, and get_calendar_events for the same window to pick up ` +
|
|
44
|
+
"exams, labs, and other scheduled events. Group the work by day, put the heaviest or " +
|
|
45
|
+
"most time-sensitive items earliest, leave a buffer day before anything that looks " +
|
|
46
|
+
"like an exam, and call out any day with more due than looks reasonable to finish.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Register the weekly_briefing prompt.
|
|
8
|
+
*
|
|
9
|
+
* Takes no arguments: it asks for a rolled-up status of the coming week
|
|
10
|
+
* across every enrolled course, which is exactly the kind of thing a user
|
|
11
|
+
* opens their client just to ask, every Monday, without typing it out.
|
|
12
|
+
*/
|
|
13
|
+
export function registerWeeklyBriefingPrompt(server) {
|
|
14
|
+
server.registerPrompt("weekly_briefing", {
|
|
15
|
+
title: "Weekly Briefing",
|
|
16
|
+
description: "A 7-day rollup of what's due, what's new, and what's changed in grades across every enrolled course.",
|
|
17
|
+
// No argsSchema: this prompt takes no arguments. An empty `argsSchema: {}`
|
|
18
|
+
// would make the SDK validate `request.params.arguments` against
|
|
19
|
+
// z.object({}), which rejects a GetPrompt call that omits `arguments`
|
|
20
|
+
// entirely (some MCP clients do). Omitting argsSchema takes the SDK's
|
|
21
|
+
// no-args path instead, which accepts both a missing and an empty
|
|
22
|
+
// `arguments` field.
|
|
23
|
+
}, async () => ({
|
|
24
|
+
messages: [
|
|
25
|
+
{
|
|
26
|
+
role: "user",
|
|
27
|
+
content: {
|
|
28
|
+
type: "text",
|
|
29
|
+
text: "Give me a briefing for the next 7 days across all my Brightspace courses. " +
|
|
30
|
+
"Use get_upcoming_due_dates (daysAhead: 7) for what's due, get_calendar_events " +
|
|
31
|
+
"(the next 7 days) for exams, labs, and other scheduled events, " +
|
|
32
|
+
"get_announcements (modifiedSince: 7 days ago) for anything new course staff " +
|
|
33
|
+
"posted, and get_my_grades to call out any grade that changed or any item that " +
|
|
34
|
+
"newly posted a score. Organize the result by course, lead with anything due in " +
|
|
35
|
+
"the next 48 hours, and flag announcements or grade changes that need a reply or " +
|
|
36
|
+
"a decision from me.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
@@ -11,6 +11,7 @@ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
12
12
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
13
13
|
import { assignmentUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
14
|
+
import { dueIn } from "../utils/due-in.js";
|
|
14
15
|
/** The display HTML of a quiz rich-text field, nested shape or flat. */
|
|
15
16
|
function richTextHtml(field) {
|
|
16
17
|
if (!field)
|
|
@@ -165,6 +166,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
165
166
|
? convertHtmlToMarkdown(folder.CustomInstructions.Html).markdown
|
|
166
167
|
: "",
|
|
167
168
|
dueDate: folder.DueDate,
|
|
169
|
+
dueIn: dueIn(folder.DueDate),
|
|
168
170
|
points: folder.Assessment?.ScoreDenominator ?? null,
|
|
169
171
|
isGroup: folder.GroupTypeId !== null,
|
|
170
172
|
rubric: folder.Assessment?.Rubrics?.map((r) => ({
|
|
@@ -288,6 +290,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
288
290
|
? convertHtmlToMarkdown(descriptionHtml).markdown
|
|
289
291
|
: "",
|
|
290
292
|
dueDate: quiz.DueDate,
|
|
293
|
+
dueIn: dueIn(quiz.DueDate),
|
|
291
294
|
startDate: quiz.StartDate,
|
|
292
295
|
endDate: quiz.EndDate,
|
|
293
296
|
timeLimit: timeLimit?.IsEnforced ? timeLimit.TimeLimitValue : null,
|
|
@@ -349,6 +352,7 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
|
349
352
|
// Always null: a grade column carries no due date of its own, and
|
|
350
353
|
// inventing one from the column name would be a guess.
|
|
351
354
|
dueDate: null,
|
|
355
|
+
dueIn: null,
|
|
352
356
|
url: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
|
|
353
357
|
});
|
|
354
358
|
}
|
|
@@ -4,13 +4,43 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { readMicrosoftSession } from "../auth/microsoft-session.js";
|
|
7
|
+
import { SessionStore } from "../auth/session-store.js";
|
|
7
8
|
import { getConfigStorePath } from "../utils/config-store.js";
|
|
8
9
|
import { GetServerInfoSchema } from "./schemas.js";
|
|
9
10
|
import { toolResponse } from "./tool-helpers.js";
|
|
11
|
+
/**
|
|
12
|
+
* Read whatever non-secret identity the persisted session carries, straight
|
|
13
|
+
* off disk. This is local file + native keyring access only (no network),
|
|
14
|
+
* and never throws: a locked keyring or a corrupted/absent session file just
|
|
15
|
+
* means "don't report an identity", not a failed tool call. Identity is
|
|
16
|
+
* reported regardless of whether the underlying token itself is still valid
|
|
17
|
+
* — it answers "who last signed in", not "is that session still usable".
|
|
18
|
+
*
|
|
19
|
+
* Uses SessionStore.peek() rather than load(): load() silently upgrades a
|
|
20
|
+
* legacy version-1 session file in place (a write) the first time anything
|
|
21
|
+
* reads it, and this tool is called just to report status, often repeatedly
|
|
22
|
+
* and never as part of establishing a session. A read-only status check
|
|
23
|
+
* should not be the thing that migrates on-disk session state.
|
|
24
|
+
*/
|
|
25
|
+
async function defaultReadSignedInIdentity(sessionDir) {
|
|
26
|
+
try {
|
|
27
|
+
const token = await new SessionStore(sessionDir).peek();
|
|
28
|
+
if (!token || (!token.uniqueName && !token.displayName))
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
...(token.uniqueName ? { uniqueName: token.uniqueName } : {}),
|
|
32
|
+
...(token.displayName ? { displayName: token.displayName } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
10
39
|
/**
|
|
11
40
|
* Register get_server_info tool.
|
|
12
41
|
*
|
|
13
|
-
* Answers from the startup config alone
|
|
42
|
+
* Answers from the startup config alone, plus a local (non-network) read of
|
|
43
|
+
* the persisted session for signedInAs: no network call, so it can never
|
|
14
44
|
* trigger a sign-in. hasStoredCredential reflects the keychain lookup made at
|
|
15
45
|
* startup, which is the credential this process actually holds.
|
|
16
46
|
* microsoftSession comes from the plain summary saved beside the browser
|
|
@@ -18,14 +48,21 @@ import { toolResponse } from "./tool-helpers.js";
|
|
|
18
48
|
* client's own lightweight counters (apiClient.stats()) — a snapshot of
|
|
19
49
|
* what has happened so far this process, not a network call of its own.
|
|
20
50
|
* Nothing secret — password, username, token, cookie — is ever included.
|
|
51
|
+
*
|
|
52
|
+
* readSignedInIdentity is an injection seam: production leaves it to read
|
|
53
|
+
* the real encrypted session store (native keyring and all), tests supply a
|
|
54
|
+
* fake so they never have to touch native credential storage.
|
|
21
55
|
*/
|
|
22
|
-
export function registerGetServerInfo(server, config, version, apiClient) {
|
|
56
|
+
export function registerGetServerInfo(server, config, version, apiClient, readSignedInIdentity = () => defaultReadSignedInIdentity(config.sessionDir)) {
|
|
23
57
|
server.registerTool("get_server_info", {
|
|
24
58
|
title: "Get Server Info",
|
|
25
|
-
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting
|
|
59
|
+
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
|
|
26
60
|
inputSchema: GetServerInfoSchema,
|
|
27
61
|
}, async () => {
|
|
28
|
-
const microsoftSession = await
|
|
62
|
+
const [microsoftSession, signedInAs] = await Promise.all([
|
|
63
|
+
readMicrosoftSession(config.sessionDir),
|
|
64
|
+
readSignedInIdentity(),
|
|
65
|
+
]);
|
|
29
66
|
return toolResponse({
|
|
30
67
|
version,
|
|
31
68
|
node: process.version,
|
|
@@ -35,7 +72,15 @@ export function registerGetServerInfo(server, config, version, apiClient) {
|
|
|
35
72
|
sessionStatePath: config.sessionDir,
|
|
36
73
|
schoolUrl: config.baseUrl,
|
|
37
74
|
hasStoredCredential: config.password !== undefined,
|
|
75
|
+
localTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
76
|
+
// Minutes the local clock is ahead of UTC (the inverse sign of
|
|
77
|
+
// Date#getTimezoneOffset, which is "minutes behind UTC"). On a UTC
|
|
78
|
+
// runner getTimezoneOffset() is 0, so negating it yields -0; `|| 0`
|
|
79
|
+
// normalizes that back to 0 so Object.is-based equality (toBe(0) in
|
|
80
|
+
// tests, and any JSON consumer that cares) sees a plain zero.
|
|
81
|
+
utcOffsetMinutes: -new Date().getTimezoneOffset() || 0,
|
|
38
82
|
...(microsoftSession && { microsoftSession }),
|
|
83
|
+
...(signedInAs && { signedInAs }),
|
|
39
84
|
requests: apiClient.stats(),
|
|
40
85
|
});
|
|
41
86
|
});
|
|
@@ -8,6 +8,7 @@ import { GetUpcomingDueDatesSchema, } from "./schemas.js";
|
|
|
8
8
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
9
|
import { log } from "../utils/logger.js";
|
|
10
10
|
import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
|
|
11
|
+
import { dueIn } from "../utils/due-in.js";
|
|
11
12
|
import { resolveCourses } from "./resolve-courses.js";
|
|
12
13
|
import { fetchCourseCalendarEvents } from "./calendar-events.js";
|
|
13
14
|
/** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
|
|
@@ -55,6 +56,7 @@ function calendarItems(events, items) {
|
|
|
55
56
|
courseId: event.courseId,
|
|
56
57
|
courseName: event.courseName,
|
|
57
58
|
dueDate: event.start,
|
|
59
|
+
dueIn: dueIn(event.start),
|
|
58
60
|
startDate: null,
|
|
59
61
|
endDate: event.end ?? null,
|
|
60
62
|
...(event.location ? { location: event.location } : {}),
|
|
@@ -91,6 +93,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
91
93
|
courseId: course.id,
|
|
92
94
|
courseName: course.name,
|
|
93
95
|
dueDate: folder.DueDate,
|
|
96
|
+
dueIn: dueIn(folder.DueDate),
|
|
94
97
|
startDate: null,
|
|
95
98
|
endDate: null,
|
|
96
99
|
url: assignmentUrl(baseUrl, course.id, folder.Id),
|
|
@@ -116,6 +119,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
116
119
|
courseId: course.id,
|
|
117
120
|
courseName: course.name,
|
|
118
121
|
dueDate,
|
|
122
|
+
dueIn: dueIn(dueDate),
|
|
119
123
|
startDate: quiz.StartDate ?? null,
|
|
120
124
|
endDate: quiz.EndDate ?? null,
|
|
121
125
|
url: quizUrl(baseUrl, course.id, quiz.QuizId),
|
|
@@ -138,6 +142,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
138
142
|
courseId: course.id,
|
|
139
143
|
courseName: course.name,
|
|
140
144
|
dueDate: topic.DueDate,
|
|
145
|
+
dueIn: dueIn(topic.DueDate),
|
|
141
146
|
startDate: null,
|
|
142
147
|
endDate: null,
|
|
143
148
|
url: discussionUrl(baseUrl, course.id, topic.TopicId),
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
const MINUTE_MS = 60 * 1000;
|
|
7
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
8
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
9
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
10
|
+
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
11
|
+
/**
|
|
12
|
+
* Render a due date as a short relative phrase ("in 3 days", "yesterday",
|
|
13
|
+
* "in 2 hours") so a caller doesn't have to do date math on a raw ISO
|
|
14
|
+
* timestamp itself. Unit scales with magnitude: minutes under an hour, hours
|
|
15
|
+
* under a day, days under a week, weeks beyond that.
|
|
16
|
+
*
|
|
17
|
+
* Returns `null` for a missing or unparseable date rather than throwing —
|
|
18
|
+
* D2L-supplied date strings aren't guaranteed well-formed (same guard as
|
|
19
|
+
* JhostinAleck/brightspace-mcp's `shared-kernel/date/parseValidDate.ts`, MIT).
|
|
20
|
+
*
|
|
21
|
+
* `now` defaults to `Date.now()`; tests pin it with fake timers.
|
|
22
|
+
*/
|
|
23
|
+
export function dueIn(dueDate, now = Date.now()) {
|
|
24
|
+
if (!dueDate)
|
|
25
|
+
return null;
|
|
26
|
+
const due = new Date(dueDate).getTime();
|
|
27
|
+
if (!Number.isFinite(due))
|
|
28
|
+
return null;
|
|
29
|
+
const diffMs = due - now;
|
|
30
|
+
const absMs = Math.abs(diffMs);
|
|
31
|
+
if (absMs < HOUR_MS) {
|
|
32
|
+
return relativeTimeFormat.format(Math.round(diffMs / MINUTE_MS), "minute");
|
|
33
|
+
}
|
|
34
|
+
if (absMs < DAY_MS) {
|
|
35
|
+
return relativeTimeFormat.format(Math.round(diffMs / HOUR_MS), "hour");
|
|
36
|
+
}
|
|
37
|
+
if (absMs < WEEK_MS) {
|
|
38
|
+
return relativeTimeFormat.format(Math.round(diffMs / DAY_MS), "day");
|
|
39
|
+
}
|
|
40
|
+
return relativeTimeFormat.format(Math.round(diffMs / WEEK_MS), "week");
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.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",
|