brightspace-mcp-server 3.9.13 → 3.9.15

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.
@@ -129,8 +129,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
129
129
  // Skip hidden folders
130
130
  if (folder.IsHidden)
131
131
  continue;
132
- // Fetch submissions for this folder
132
+ // Fetch submissions for this folder. Some tenants deny students access
133
+ // to mysubmissions outright (403), in which case an empty list would be
134
+ // indistinguishable from genuinely unsubmitted work — track whether the
135
+ // answer is actually known so the caller never reports denied-but-real
136
+ // work as missing.
137
+ // Adapted from JoshuaMontclair/brightspace-mcp-server (MIT).
133
138
  let submissions = [];
139
+ let submissionsKnown = true;
134
140
  try {
135
141
  const submissionsRaw = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/submissions/mysubmissions/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
136
142
  submissions = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
@@ -138,9 +144,10 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
138
144
  catch (error) {
139
145
  if (isAuthUnavailable(error))
140
146
  throw error;
141
- // 404 means no submissions yet - that's fine
147
+ // 404 is how D2L reports "nothing submitted yet" - that one is known.
142
148
  if (error?.status !== 404) {
143
- log("DEBUG", `Failed to fetch submissions for folder ${folder.Id}`, error);
149
+ submissionsKnown = false;
150
+ log("DEBUG", `Could not determine submission state for folder ${folder.Id}`, error);
144
151
  }
145
152
  }
146
153
  // Fetch feedback independently of submissions
@@ -156,6 +163,16 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
156
163
  log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
157
164
  }
158
165
  }
166
+ // Instructor-provided URL links attached to the folder. Omitted
167
+ // entirely (rather than an empty array) when the folder has none, to
168
+ // keep the common case's shape exactly as it was.
169
+ // Adapted from LunaParker/brightspace-mcp-server (MIT).
170
+ // D2L's field for the link's display text is `LinkName`; `Title` is
171
+ // kept as a fallback for tenants observed sending that spelling
172
+ // instead. Adapted from JhostinAleck/brightspace-mcp (MIT).
173
+ const linkAttachments = (folder.LinkAttachments ?? [])
174
+ .filter((l) => Boolean(l.Href))
175
+ .map((l) => ({ name: l.LinkName ?? l.Title ?? l.Href ?? null, url: l.Href }));
159
176
  // Build assignment object
160
177
  const assignment = {
161
178
  type: "assignment",
@@ -169,6 +186,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
169
186
  dueIn: dueIn(folder.DueDate),
170
187
  points: folder.Assessment?.ScoreDenominator ?? null,
171
188
  isGroup: folder.GroupTypeId !== null,
189
+ ...(linkAttachments.length > 0 ? { linkAttachments } : {}),
172
190
  rubric: folder.Assessment?.Rubrics?.map((r) => ({
173
191
  name: r.Name,
174
192
  criteria: r.Criteria?.map((c) => ({
@@ -180,6 +198,19 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
180
198
  })) ?? [],
181
199
  })) ?? [],
182
200
  })) ?? null,
201
+ // New, additive fields: existing "submission" stays null either way
202
+ // when nothing is known, but submissionStatus tells apart "nothing
203
+ // was submitted" (404, a known answer) from "the answer is denied"
204
+ // (any other error), so a caller never states real work as missing.
205
+ submissionStatus: !submissionsKnown
206
+ ? "unknown"
207
+ : submissions.length > 0
208
+ ? "submitted"
209
+ : "not_submitted",
210
+ ...(submissionsKnown ? {} : {
211
+ submissionStatusNote: "Brightspace did not return this assignment's submission list, so whether it was submitted " +
212
+ "is unknown. Do not report it as missing or unsubmitted - tell the user to check Brightspace directly.",
213
+ }),
183
214
  submission: submissions.length > 0
184
215
  ? {
185
216
  submittedDate: submissions[0].SubmissionDate,
@@ -300,6 +331,16 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
300
331
  // False when the tenant refused the attempts endpoint, in which case
301
332
  // every count below is null rather than a guess of zero.
302
333
  attemptsAvailable: completedAttempts !== null,
334
+ // New, additive field mirroring submissionStatus above: "known" is
335
+ // completedAttempts !== null (the same condition attemptsAvailable
336
+ // already tracks), named explicitly so a caller does not have to
337
+ // infer "unknown" from a boolean meant for something else.
338
+ // Adapted from JoshuaMontclair/brightspace-mcp-server (MIT).
339
+ attemptStatus: completedAttempts !== null ? "known" : "unknown",
340
+ ...(completedAttempts !== null ? {} : {
341
+ attemptStatusNote: "Brightspace did not provide this quiz's attempt data, so it is unknown whether it was taken. " +
342
+ "Do not report it as not attempted.",
343
+ }),
303
344
  attemptsUsed: completedAttempts?.length ?? null,
304
345
  attemptsRemaining,
305
346
  attemptWarning,
@@ -364,7 +405,8 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
364
405
  export function registerGetAssignments(server, apiClient, config) {
365
406
  server.registerTool("get_assignments", {
366
407
  title: "Get Assignments",
367
- description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics.",
408
+ description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
409
+ "Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace.",
368
410
  inputSchema: GetAssignmentsSchema,
369
411
  }, async (args) => {
370
412
  try {
@@ -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);
@@ -7,15 +7,33 @@ import * as path from "node:path";
7
7
  import * as os from "node:os";
8
8
  import { createHash } from "node:crypto";
9
9
  import dotenv from "dotenv";
10
- import { configStoreExists, loadConfigStore } from "./config-store.js";
10
+ import { configStoreExists, getConfigStorePath, loadConfigStore } from "./config-store.js";
11
11
  import { resolveStoredPassword } from "./secure-config.js";
12
12
  import { migrateLegacyState } from "../auth/legacy-state.js";
13
13
  export async function loadConfig() {
14
14
  dotenv.config({ quiet: true });
15
- const store = configStoreExists() ? loadConfigStore() : null;
15
+ // A corrupt or permission-denied config.json must not take the whole server
16
+ // down at startup: env vars alone are a complete, if less convenient,
17
+ // configuration. The failure is still surfaced loudly (never silent) so a
18
+ // broken file doesn't masquerade as "no config.json was ever created".
19
+ // Idea from lmgveerhoek's fork (MIT).
20
+ let store = null;
21
+ let storeLoadError;
22
+ if (configStoreExists()) {
23
+ try {
24
+ store = loadConfigStore();
25
+ }
26
+ catch (error) {
27
+ storeLoadError = error;
28
+ }
29
+ }
16
30
  if (store) {
17
31
  console.error("[config] Loaded base config from ~/.brightspace-mcp/config.json");
18
32
  }
33
+ else if (storeLoadError) {
34
+ console.error(`[config] WARN: Failed to read ${getConfigStorePath()} (${storeLoadError instanceof Error ? storeLoadError.message : String(storeLoadError)}); ` +
35
+ "continuing with environment variables only.");
36
+ }
19
37
  else {
20
38
  console.error("[config] No config.json found, using environment variables");
21
39
  }
@@ -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.13",
3
+ "version": "3.9.15",
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",