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
|
|
147
|
+
// 404 is how D2L reports "nothing submitted yet" - that one is known.
|
|
142
148
|
if (error?.status !== 404) {
|
|
143
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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}) -
|
|
90
|
-
return
|
|
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
|
|
97
|
-
.filter((r) => r.status === "fulfilled"
|
|
125
|
+
const settled = results
|
|
126
|
+
.filter((r) => r.status === "fulfilled")
|
|
98
127
|
.map((r) => r.value);
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
package/build/utils/config.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|