brightspace-mcp-server 3.9.12 → 3.9.14

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.
@@ -295,11 +295,18 @@ export class PurdueSSOFlow {
295
295
  return await this.hasCoVisiblePassword(page) && !await this.anyVisible(page, EMAIL_SELECTORS);
296
296
  }
297
297
  async hasPostCredentialChallenge(page) {
298
+ // Entra can land directly on its bare verification-code form - no
299
+ // number-match digits, no #idDiv_SAOTCAS_Title/#idDiv_SAOTCC_Title
300
+ // heading - when resuming a restored session. Without MFA_CODE_SELECTORS
301
+ // here, login() concluded no challenge was present and re-ran
302
+ // enterCredentials against a page with no username/password field.
303
+ // Idea from ElliotDrel/brightspace-mcp (branch codex/purdue-totp).
298
304
  return this.duoMfa.isChallenge(page) || await this.anyVisible(page, [
299
305
  NUMBER_MATCH_SELECTOR,
300
306
  "#idDiv_SAOTCAS_Title",
301
307
  "#idDiv_SAOTCC_Title",
302
308
  "#KmsiCheckboxField",
309
+ ...MFA_CODE_SELECTORS,
303
310
  ]);
304
311
  }
305
312
  /** Brightspace Bar's bounded number/auth/KMSI polling loop. */
@@ -156,6 +156,16 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
156
156
  log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
157
157
  }
158
158
  }
159
+ // Instructor-provided URL links attached to the folder. Omitted
160
+ // entirely (rather than an empty array) when the folder has none, to
161
+ // keep the common case's shape exactly as it was.
162
+ // Adapted from LunaParker/brightspace-mcp-server (MIT).
163
+ // D2L's field for the link's display text is `LinkName`; `Title` is
164
+ // kept as a fallback for tenants observed sending that spelling
165
+ // instead. Adapted from JhostinAleck/brightspace-mcp (MIT).
166
+ const linkAttachments = (folder.LinkAttachments ?? [])
167
+ .filter((l) => Boolean(l.Href))
168
+ .map((l) => ({ name: l.LinkName ?? l.Title ?? l.Href ?? null, url: l.Href }));
159
169
  // Build assignment object
160
170
  const assignment = {
161
171
  type: "assignment",
@@ -169,6 +179,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
169
179
  dueIn: dueIn(folder.DueDate),
170
180
  points: folder.Assessment?.ScoreDenominator ?? null,
171
181
  isGroup: folder.GroupTypeId !== null,
182
+ ...(linkAttachments.length > 0 ? { linkAttachments } : {}),
172
183
  rubric: folder.Assessment?.Rubrics?.map((r) => ({
173
184
  name: r.Name,
174
185
  criteria: r.Criteria?.map((c) => ({
@@ -6,9 +6,10 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems } from "../api/paginate.js";
8
8
  import { GetMyGradesSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, errorResponse } 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
13
  /**
13
14
  * Register get_my_grades tool
14
15
  */
@@ -25,9 +26,27 @@ export function registerGetMyGrades(server, apiClient, config) {
25
26
  // Single course case
26
27
  if (courseId) {
27
28
  const path = apiClient.le(courseId, "/grades/values/myGradeValues/");
28
- const gradeValues = await apiClient.get(path, {
29
- ttl: DEFAULT_CACHE_TTLS.grades,
30
- });
29
+ let gradeValues;
30
+ try {
31
+ gradeValues = await apiClient.get(path, {
32
+ ttl: DEFAULT_CACHE_TTLS.grades,
33
+ });
34
+ }
35
+ catch (error) {
36
+ // A 403 here means the tenant restricts the grade API for this
37
+ // course (an institutional policy, not a bug). The error shape
38
+ // stays the same plain isError text result every other tool
39
+ // error uses -- only the text itself changes, naming the
40
+ // gradebook URL so the student can see their grade there
41
+ // instead.
42
+ if (error?.status === 403) {
43
+ const url = gradebookUrl(config.baseUrl, courseId);
44
+ log("INFO", `get_my_grades: 403 for course ${courseId} - grade API access restricted`);
45
+ return errorResponse("Your institution restricts grade API access for this course. " +
46
+ `View your grades directly in Brightspace: ${url}`);
47
+ }
48
+ throw error;
49
+ }
31
50
  // Map to clean objects
32
51
  const grades = gradeValues.map((gv) => ({
33
52
  name: gv.GradeObjectName,
@@ -78,26 +97,43 @@ export function registerGetMyGrades(server, apiClient, config) {
78
97
  lastModified: gv.LastModified,
79
98
  }));
80
99
  return {
100
+ restricted: false,
81
101
  courseId: item.OrgUnit.Id,
82
102
  courseName: item.OrgUnit.Name,
83
103
  grades,
84
104
  };
85
105
  }
86
106
  catch (error) {
87
- // 403 means no access (past course, etc) - log and skip
107
+ // A 403 means the tenant restricts the grade API for this course
108
+ // (an institutional policy, not missing access like a dropped
109
+ // past course). Rather than dropping the course with no trace,
110
+ // it is surfaced in a separate, purely additive
111
+ // `restrictedCourses` array; `courses` itself is unaffected.
88
112
  if (error?.status === 403) {
89
- log("DEBUG", `get_my_grades: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
90
- return null;
113
+ log("DEBUG", `get_my_grades: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - grade API access restricted`);
114
+ return {
115
+ restricted: true,
116
+ courseId: item.OrgUnit.Id,
117
+ courseName: item.OrgUnit.Name,
118
+ gradeUrl: gradebookUrl(config.baseUrl, item.OrgUnit.Id),
119
+ };
91
120
  }
92
121
  throw error; // Re-throw other errors
93
122
  }
94
123
  });
95
124
  const results = await Promise.allSettled(gradePromises);
96
- const courses = results
97
- .filter((r) => r.status === "fulfilled" && r.value !== null)
125
+ const settled = results
126
+ .filter((r) => r.status === "fulfilled")
98
127
  .map((r) => r.value);
99
- log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${enrollmentItems.length} enrolled)`);
100
- return toolResponse({ courses });
128
+ const courses = settled
129
+ .filter((r) => !r.restricted)
130
+ .map(({ restricted: _restricted, ...rest }) => rest);
131
+ const restrictedCourses = settled
132
+ .filter((r) => r.restricted)
133
+ .map(({ restricted: _restricted, ...rest }) => rest);
134
+ log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
135
+ `${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled)`);
136
+ return toolResponse({ courses, restrictedCourses });
101
137
  }
102
138
  catch (error) {
103
139
  return sanitizeError(error);
@@ -85,6 +85,11 @@ export const ALLOWED_MIME_TYPES = [
85
85
  // Archives
86
86
  "application/zip",
87
87
  "application/x-zip-compressed",
88
+ "application/x-7z-compressed",
89
+ "application/gzip",
90
+ "application/x-gzip",
91
+ "application/x-tar",
92
+ "application/x-bzip2",
88
93
  // Media
89
94
  "video/mp4",
90
95
  "audio/mpeg",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.12",
3
+ "version": "3.9.14",
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",