brightspace-mcp-server 3.10.13 → 3.10.16

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 CHANGED
@@ -65,12 +65,13 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
65
65
 
66
66
  | About | Try |
67
67
  |-------|-----|
68
- | Grades | "Am I passing all my classes?" · "Compare my grades across courses" |
68
+ | Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
69
69
  | Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
70
70
  | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
71
71
  | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
72
72
  | Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
73
73
  | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
74
+ | Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
74
75
  | People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
75
76
  | Discussions | "Summarize the latest posts in the final project thread" |
76
77
  | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
@@ -105,7 +106,7 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
105
106
 
106
107
  ## When it asks you to sign in
107
108
 
108
- There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and ask again. If your AI client shows tool progress messages, the number appears while the question is still running instead, and approving within 45 seconds (less if sign-in was slow to reach two-factor) finishes the original answer without asking again. If it ever gets stuck, run this in a terminal:
109
+ There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and the assistant keeps checking on its own — each check waits up to 45 seconds and the original question completes as soon as the sign-in does, without you having to say you've approved it. If your AI client shows tool progress messages, the number appears while the question is still running instead. If it ever gets stuck, run this in a terminal:
109
110
 
110
111
  ```bash
111
112
  npx -y brightspace-mcp-server@latest auth
@@ -21,18 +21,13 @@ import { devActivity } from "../utils/dev-activity.js";
21
21
  const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
22
22
  const KILL_GRACE_MS = 5000;
23
23
  /**
24
- * How long a caller that joins a background child already reported as
25
- * mfaPending waits for it before re-answering with the same challenge.
26
- * Long enough for an already-approved login to finish its next ~2s poll and
27
- * mint a token; short enough that a not-yet-approved one still answers
28
- * within this tool call instead of blocking for the rest of the 5-minute
29
- * approval window.
30
- */
31
- const JOIN_GRACE_MS = 5000;
32
- /**
33
- * How long a caller that can be told the challenge mid-call (see run()'s
34
- * onChallenge) keeps waiting for the background sign-in after the challenge
35
- * appears: long enough to find a phone and approve.
24
+ * How long a caller keeps waiting for the background sign-in once the
25
+ * challenge is known: long enough to find a phone and approve. Applies both
26
+ * to a caller told the challenge mid-call (see run()'s onChallenge) and to a
27
+ * retry that joins the background child after an early answer. The retry
28
+ * used to wait only 5 s, so on clients that send no progress token (Claude
29
+ * Desktop) every retry re-answered "enter the number" and the model asked
30
+ * the user to confirm by hand instead of the server polling for the approval.
36
31
  */
37
32
  const MFA_POLL_MS = 45000;
38
33
  /**
@@ -182,18 +177,20 @@ export class AuthRunner {
182
177
  * Authenticate, joining the login this process already started if there is
183
178
  * one. Returns true on success and throws a useful error on failure.
184
179
  *
185
- * Without onChallenge, an MFA challenge answers the caller at once with an
186
- * mfaPending error. With it, the caller is handed the challenge (the
187
- * number to enter, if any) and keeps waiting on the background sign-in for
188
- * up to MFA_POLL_MS, and never past CALL_BUDGET_MS from the call, so an
189
- * approval within that window completes the caller's original request;
190
- * past it, the caller gets the same mfaPending answer it would have had up
191
- * front.
180
+ * Without onChallenge, a NEW sign-in answers the caller at once when an MFA
181
+ * challenge appears (an mfaPending error carrying the number to enter: the
182
+ * user cannot see it any other way), and the next call joins the background
183
+ * sign-in and polls it for up to MFA_POLL_MS, never past CALL_BUDGET_MS from
184
+ * that call. With onChallenge, the caller is handed the challenge mid-call
185
+ * and the first call itself keeps waiting the same way. Either way an
186
+ * approval within the window completes the caller's original request; past
187
+ * it, the caller gets the same mfaPending answer again and is expected to
188
+ * call once more.
192
189
  */
193
190
  async run(onChallenge) {
194
191
  const startedAt = Date.now();
195
192
  try {
196
- return await this.runOnce();
193
+ return await this.runOnce(startedAt);
197
194
  }
198
195
  catch (error) {
199
196
  const childDone = this.childDone;
@@ -209,7 +206,7 @@ export class AuthRunner {
209
206
  return this.awaitBackgroundChild(childDone, windowMs);
210
207
  }
211
208
  }
212
- runOnce() {
209
+ runOnce(startedAt) {
213
210
  if (this.inFlight) {
214
211
  log("DEBUG", "Joining the authentication already in flight");
215
212
  return this.inFlight;
@@ -220,7 +217,7 @@ export class AuthRunner {
220
217
  // cross-process lock and return "busy".
221
218
  if (this.childDone) {
222
219
  log("DEBUG", "Joining the background sign-in still running from an earlier call");
223
- return this.joinBackgroundChild(this.childDone);
220
+ return this.joinBackgroundChild(this.childDone, startedAt);
224
221
  }
225
222
  // The latch is released by the flow that owns it, as it settles, so a
226
223
  // failed login is never replayed: the tool call after a declined MFA
@@ -237,23 +234,23 @@ export class AuthRunner {
237
234
  /**
238
235
  * Join a background child from an earlier early-answered call instead of
239
236
  * spawning a new one. A joiner must not simply await childDone: that
240
- * blocks for whatever is left of the 5-minute approval window, exactly the
241
- * problem run() otherwise fixes, since the caller usually retries right
242
- * after reading "call this tool again" and well before actually approving.
237
+ * blocks for whatever is left of the 5-minute approval window, past the
238
+ * client's request timeout.
243
239
  *
244
240
  * If no challenge has been reported yet (the child hasn't reached MFA),
245
241
  * wait for one — or for the child to finish on its own. Once a challenge
246
- * is known, race the child against a short grace window: fast enough for
247
- * an already-approved login to land, short enough to re-answer with the
248
- * same challenge rather than block.
242
+ * is known, poll the child for the rest of this call's budget: the user
243
+ * read the number from the previous answer and is approving right now, so
244
+ * this is the call that should carry the result. A still-unapproved
245
+ * sign-in re-answers with the latest challenge and the caller tries again.
249
246
  */
250
- async joinBackgroundChild(childDone) {
247
+ async joinBackgroundChild(childDone, startedAt) {
251
248
  if (!this.pendingChallenge && this.challengeSignal) {
252
249
  await Promise.race([childDone.catch(() => { }), this.challengeSignal]);
253
250
  }
254
251
  if (!this.pendingChallenge)
255
252
  return childDone;
256
- return this.awaitBackgroundChild(childDone, JOIN_GRACE_MS);
253
+ return this.awaitBackgroundChild(childDone, Math.max(0, pollWindowMs(Date.now() - startedAt)));
257
254
  }
258
255
  /**
259
256
  * Race a background child already past its MFA challenge against a wait
@@ -9,7 +9,58 @@ import { GetMyGradesSchema, } from "./schemas.js";
9
9
  import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
- import { gradebookUrl } from "../utils/deep-links.js";
12
+ import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
13
+ /**
14
+ * The quiz a grade row is scored from can hold feedback the gradebook does
15
+ * not, and the student API cannot read it: quiz attempts are refused to
16
+ * students, and some quizzes only show feedback inside a restricted viewer.
17
+ */
18
+ const QUIZ_FEEDBACK_NOTE = "The gradebook has no comment for this item, but it is scored from a quiz, and quiz " +
19
+ "feedback lives on the quiz's own submissions page, which Brightspace does not expose " +
20
+ "to the student API. Feedback may exist there, sometimes viewable only in a restricted " +
21
+ "browser such as Respondus LockDown Browser. Open feedbackUrl to check; do not report " +
22
+ "this item as having no feedback.";
23
+ /**
24
+ * The course's quizzes, or none when the route refuses or fails. A missing
25
+ * quiz list only costs the feedback links, never the grades themselves.
26
+ */
27
+ async function fetchQuizzes(apiClient, courseId) {
28
+ try {
29
+ const raw = await apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
30
+ return Array.isArray(raw) ? raw : raw?.Objects ?? [];
31
+ }
32
+ catch (error) {
33
+ log("DEBUG", `get_my_grades: quiz list unavailable for course ${courseId}`, error);
34
+ return [];
35
+ }
36
+ }
37
+ /**
38
+ * Clean grade rows. Rows scored from a quiz also carry feedbackUrl, and a
39
+ * feedbackNote when the gradebook comment is empty; other rows are unchanged.
40
+ */
41
+ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
42
+ const quizByGradeItem = new Map(quizzes
43
+ .filter((q) => q.GradeItemId != null)
44
+ .map((q) => [String(q.GradeItemId), q.QuizId]));
45
+ return gradeValues.map((gv) => {
46
+ const comments = gv.Comments?.Text || null;
47
+ const quizId = quizByGradeItem.get(String(gv.GradeObjectIdentifier));
48
+ return {
49
+ name: gv.GradeObjectName,
50
+ displayGrade: gv.DisplayedGrade,
51
+ pointsNumerator: gv.PointsNumerator,
52
+ pointsDenominator: gv.PointsDenominator,
53
+ weightedNumerator: gv.WeightedNumerator,
54
+ weightedDenominator: gv.WeightedDenominator,
55
+ comments,
56
+ lastModified: gv.LastModified,
57
+ ...(quizId === undefined ? {} : {
58
+ feedbackUrl: quizUrl(baseUrl, courseId, quizId),
59
+ ...(comments ? {} : { feedbackNote: QUIZ_FEEDBACK_NOTE }),
60
+ }),
61
+ };
62
+ });
63
+ }
13
64
  /**
14
65
  * Register get_my_grades tool
15
66
  */
@@ -26,6 +77,7 @@ export function registerGetMyGrades(server, apiClient, config) {
26
77
  // Single course case
27
78
  if (courseId) {
28
79
  const path = apiClient.le(courseId, "/grades/values/myGradeValues/");
80
+ const quizzesPromise = fetchQuizzes(apiClient, courseId);
29
81
  let gradeValues;
30
82
  try {
31
83
  gradeValues = await apiClient.get(path, {
@@ -65,16 +117,7 @@ export function registerGetMyGrades(server, apiClient, config) {
65
117
  throw error;
66
118
  }
67
119
  // Map to clean objects
68
- const grades = gradeValues.map((gv) => ({
69
- name: gv.GradeObjectName,
70
- displayGrade: gv.DisplayedGrade,
71
- pointsNumerator: gv.PointsNumerator,
72
- pointsDenominator: gv.PointsDenominator,
73
- weightedNumerator: gv.WeightedNumerator,
74
- weightedDenominator: gv.WeightedDenominator,
75
- comments: gv.Comments?.Text || null,
76
- lastModified: gv.LastModified,
77
- }));
120
+ const grades = toGradeItems(gradeValues, await quizzesPromise, config.baseUrl, courseId);
78
121
  log("INFO", `get_my_grades: Retrieved ${grades.length} grade items for course ${courseId}`);
79
122
  return toolResponse({ courseId, grades });
80
123
  }
@@ -102,19 +145,11 @@ export function registerGetMyGrades(server, apiClient, config) {
102
145
  const gradePromises = filteredEnrollments.map(async (item) => {
103
146
  try {
104
147
  const path = apiClient.le(item.OrgUnit.Id, "/grades/values/myGradeValues/");
105
- const gradeValues = await apiClient.get(path, {
106
- ttl: DEFAULT_CACHE_TTLS.grades,
107
- });
108
- const grades = gradeValues.map((gv) => ({
109
- name: gv.GradeObjectName,
110
- displayGrade: gv.DisplayedGrade,
111
- pointsNumerator: gv.PointsNumerator,
112
- pointsDenominator: gv.PointsDenominator,
113
- weightedNumerator: gv.WeightedNumerator,
114
- weightedDenominator: gv.WeightedDenominator,
115
- comments: gv.Comments?.Text || null,
116
- lastModified: gv.LastModified,
117
- }));
148
+ const [gradeValues, quizzes] = await Promise.all([
149
+ apiClient.get(path, { ttl: DEFAULT_CACHE_TTLS.grades }),
150
+ fetchQuizzes(apiClient, item.OrgUnit.Id),
151
+ ]);
152
+ const grades = toGradeItems(gradeValues, quizzes, config.baseUrl, item.OrgUnit.Id);
118
153
  return {
119
154
  restricted: false,
120
155
  courseId: item.OrgUnit.Id,
@@ -13,13 +13,61 @@ import { extractPdfText } from "../utils/pdf-extractor.js";
13
13
  import { log } from "../utils/logger.js";
14
14
  import path from "node:path";
15
15
  import fs from "node:fs/promises";
16
+ const SYLLABUS_PATTERN = /syllab/i;
17
+ const LTI_LAUNCH_PATTERN = /[?&]type=lti\b|\/d2l\/le\/lti\//i;
18
+ const EXTERNAL_SYLLABUS_NOTE = "This course links its syllabus through an external LTI tool. The server lists these sources but cannot launch LTI tools, so their contents are not included; open the link in Brightspace to read it.";
19
+ /** Launch URL without its query string, which can carry launch parameters. */
20
+ function stripQuery(url) {
21
+ return url.split(/[?#]/)[0];
22
+ }
23
+ /**
24
+ * Pick the syllabus-related LTI sources out of the course's LTI links and
25
+ * content tree. Pure: the caller fetches, this only decides.
26
+ */
27
+ function findExternalSyllabusSources(ltiLinks, toc) {
28
+ const sources = ltiLinks
29
+ .filter((l) => l.IsVisible !== false && (SYLLABUS_PATTERN.test(l.Title) || SYLLABUS_PATTERN.test(l.Url)))
30
+ .map((l) => ({ title: l.Title, location: "lti-link", url: stripQuery(l.Url) }));
31
+ const walk = (modules) => {
32
+ for (const m of modules) {
33
+ for (const t of m.Topics ?? []) {
34
+ if (t.Url && LTI_LAUNCH_PATTERN.test(t.Url) && SYLLABUS_PATTERN.test(t.Title)) {
35
+ sources.push({ title: t.Title, location: "content", url: t.Url });
36
+ }
37
+ }
38
+ walk(m.Modules ?? []);
39
+ }
40
+ };
41
+ walk(toc);
42
+ return sources;
43
+ }
44
+ /** Fetch a lookup that students may not be permitted to make; failure means "none". */
45
+ async function fetchOrEmpty(fetch, fallback, what) {
46
+ try {
47
+ return await fetch();
48
+ }
49
+ catch (error) {
50
+ log("DEBUG", `get_syllabus: could not read ${what}`, error);
51
+ return fallback;
52
+ }
53
+ }
54
+ async function discoverExternalSyllabusSources(apiClient, courseId) {
55
+ const [ltiLinks, toc] = await Promise.all([
56
+ fetchOrEmpty(() => apiClient.get(apiClient.leGlobal(`/lti/link/${courseId}/`), { ttl: DEFAULT_CACHE_TTLS.courseContent }), [], "LTI links"),
57
+ fetchOrEmpty(() => apiClient.get(apiClient.le(courseId, "/content/toc"), { ttl: DEFAULT_CACHE_TTLS.courseContent }), {}, "content table of contents"),
58
+ ]);
59
+ const sources = findExternalSyllabusSources(ltiLinks ?? [], toc?.Modules ?? []);
60
+ return sources.length > 0
61
+ ? { externalSyllabusSources: sources, externalSyllabusNote: EXTERNAL_SYLLABUS_NOTE }
62
+ : {};
63
+ }
16
64
  /**
17
65
  * Register get_syllabus tool
18
66
  */
19
67
  export function registerGetSyllabus(server, apiClient) {
20
68
  server.registerTool("get_syllabus", {
21
69
  title: "Get Course Syllabus",
22
- description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
70
+ description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
23
71
  inputSchema: GetSyllabusSchema,
24
72
  }, async (args) => {
25
73
  try {
@@ -55,6 +103,7 @@ export function registerGetSyllabus(server, apiClient) {
55
103
  description: null,
56
104
  hasAttachment: false,
57
105
  message: "No syllabus/overview found for this course.",
106
+ ...(await discoverExternalSyllabusSources(apiClient, courseId)),
58
107
  });
59
108
  }
60
109
  throw error;
@@ -152,6 +201,7 @@ export function registerGetSyllabus(server, apiClient) {
152
201
  if (download) {
153
202
  result.download = download;
154
203
  }
204
+ Object.assign(result, await discoverExternalSyllabusSources(apiClient, courseId));
155
205
  return toolResponse(result);
156
206
  }
157
207
  catch (error) {
@@ -98,8 +98,10 @@ const AUTH_FAILURE_GUIDANCE = {
98
98
  `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
99
99
  failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
100
100
  "or `brightspace-setup` if your saved school or username is wrong.",
101
- mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo), " +
102
- "then call this tool again — the sign-in is finishing in the background.",
101
+ mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo). " +
102
+ "Tell the user that, then call this tool again right away without waiting for them to " +
103
+ "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
104
+ "as the sign-in completes. Keep calling until it succeeds or reports a different error.",
103
105
  };
104
106
  /**
105
107
  * mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
@@ -110,8 +112,10 @@ const AUTH_FAILURE_GUIDANCE = {
110
112
  */
111
113
  function authFailureMessage(error) {
112
114
  if (error.kind === "mfaPending" && error.numberMatch) {
113
- return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, ` +
114
- "then call this tool again — the sign-in is finishing in the background.";
115
+ return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
116
+ "Tell the user that number, then call this tool again right away without waiting for them " +
117
+ "to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
118
+ "soon as the sign-in completes. Keep calling until it succeeds or reports a different error.";
115
119
  }
116
120
  return AUTH_FAILURE_GUIDANCE[error.kind];
117
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.10.13",
3
+ "version": "3.10.16",
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",