brightspace-mcp-server 5.1.33 → 5.1.36
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/build/auth/auth-runner.js +2 -2
- package/build/auth/session-store.js +8 -3
- package/build/index.js +1 -1
- package/build/server.js +3 -2
- package/build/tools/get-available-groups.js +166 -0
- package/build/tools/get-course-content.js +78 -15
- package/build/tools/get-discussions.js +37 -22
- package/build/tools/get-my-groups.js +45 -4
- package/build/tools/index.js +1 -0
- package/build/tools/resolve-courses.js +2 -2
- package/build/tools/schemas.js +6 -0
- package/build/tools/search-course.js +5 -1
- package/package.json +1 -1
|
@@ -225,7 +225,7 @@ function descendantPids(parentPid) {
|
|
|
225
225
|
function forceStop(pid) {
|
|
226
226
|
if (process.platform === "win32") {
|
|
227
227
|
try {
|
|
228
|
-
execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000 });
|
|
228
|
+
execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000, windowsHide: true });
|
|
229
229
|
}
|
|
230
230
|
catch { /* Already exited. */ }
|
|
231
231
|
return;
|
|
@@ -900,7 +900,7 @@ export class AuthRunner {
|
|
|
900
900
|
try {
|
|
901
901
|
if (child.pid && process.platform === "win32") {
|
|
902
902
|
execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
903
|
-
stdio: "ignore", timeout: 5000,
|
|
903
|
+
stdio: "ignore", timeout: 5000, windowsHide: true,
|
|
904
904
|
});
|
|
905
905
|
}
|
|
906
906
|
else if (child.pid) {
|
|
@@ -10,7 +10,7 @@ import * as os from "node:os";
|
|
|
10
10
|
import { SessionStoreError } from "../utils/errors.js";
|
|
11
11
|
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
12
12
|
import { hasSessionEncryptionKey, NativeCredentialStoreError } from "./credential-store.js";
|
|
13
|
-
import { decrypt, readEncryptedRecord, saveEncryptedRecord
|
|
13
|
+
import { decrypt, readEncryptedRecord, saveEncryptedRecord } from "./encrypted-store.js";
|
|
14
14
|
const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".d2l-session");
|
|
15
15
|
/** The saved access token, relative to its session directory. */
|
|
16
16
|
export const SESSION_FILE = "session.json";
|
|
@@ -223,6 +223,11 @@ export class SessionStore {
|
|
|
223
223
|
this.storeError("load", error);
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Delete the saved session once it reads back. Not moved to Trash: nothing
|
|
228
|
+
* restores a session, and on Windows Trash's console helper opens a terminal
|
|
229
|
+
* window from the shared runtime, which has no console to run it in.
|
|
230
|
+
*/
|
|
226
231
|
async clear() {
|
|
227
232
|
try {
|
|
228
233
|
await this.withWriteLock(async () => {
|
|
@@ -230,7 +235,7 @@ export class SessionStore {
|
|
|
230
235
|
if (!current)
|
|
231
236
|
return;
|
|
232
237
|
await this.decode(current);
|
|
233
|
-
await
|
|
238
|
+
await fs.rm(this.sessionFilePath, { force: true });
|
|
234
239
|
});
|
|
235
240
|
}
|
|
236
241
|
catch (error) {
|
|
@@ -281,7 +286,7 @@ export class SessionStore {
|
|
|
281
286
|
return await this.withWriteLock(async () => {
|
|
282
287
|
if (!equalToken(await this.loadUnlocked(), expected))
|
|
283
288
|
return false;
|
|
284
|
-
await
|
|
289
|
+
await fs.rm(this.sessionFilePath, { force: true });
|
|
285
290
|
return true;
|
|
286
291
|
});
|
|
287
292
|
}
|
package/build/index.js
CHANGED
|
@@ -138,7 +138,7 @@ else {
|
|
|
138
138
|
const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
|
|
139
139
|
const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
|
|
140
140
|
stopServing = running.close;
|
|
141
|
-
log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (
|
|
141
|
+
log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (28 tools, 4 prompts registered)`);
|
|
142
142
|
log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
|
|
143
143
|
}
|
|
144
144
|
catch (error) {
|
package/build/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
|
|
|
9
9
|
import { trackToolActivity } from "./utils/dev-activity.js";
|
|
10
10
|
import { log } from "./utils/logger.js";
|
|
11
11
|
import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
|
|
12
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
12
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
13
13
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
14
14
|
/**
|
|
15
15
|
* Build the shared runtime. Nothing here reaches Brightspace: API versions
|
|
@@ -88,6 +88,7 @@ export function createMcpServer(runtime) {
|
|
|
88
88
|
registerGetClasslistEmails(server, apiClient);
|
|
89
89
|
registerGetRoster(server, apiClient);
|
|
90
90
|
registerGetMyGroups(server, apiClient);
|
|
91
|
+
registerGetAvailableGroups(server, apiClient, config);
|
|
91
92
|
registerGetSyllabus(server, apiClient);
|
|
92
93
|
registerGetDiscussions(server, apiClient, config.baseUrl);
|
|
93
94
|
registerGetVideoTranscript(server, apiClient);
|
|
@@ -102,7 +103,7 @@ export function createMcpServer(runtime) {
|
|
|
102
103
|
registerGetDropboxFeedback(server, apiClient);
|
|
103
104
|
registerGetRubricsForObject(server, apiClient);
|
|
104
105
|
registerDownloadDropboxSubmissionFile(server, apiClient);
|
|
105
|
-
log("DEBUG", "MCP tools registered (
|
|
106
|
+
log("DEBUG", "MCP tools registered (28 tools)");
|
|
106
107
|
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
107
108
|
// picker, distinct from tools. Each one is a canned user message that
|
|
108
109
|
// names our own tools by name, so a client with no idea what to ask
|
|
@@ -0,0 +1,166 @@
|
|
|
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 { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { ApiError } from "../api/errors.js";
|
|
8
|
+
import { GetAvailableGroupsSchema } from "./schemas.js";
|
|
9
|
+
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
10
|
+
import { fetchCurrentUserId } from "./group-membership.js";
|
|
11
|
+
import { resolveCourses } from "./resolve-courses.js";
|
|
12
|
+
import { log } from "../utils/logger.js";
|
|
13
|
+
/** D2L's GRPENROLL_T values whose groups students pick themselves. */
|
|
14
|
+
const SELF_ENROLLMENT_STYLES = new Set([3, 4, 5]);
|
|
15
|
+
/** Without a courseId, courses that started longer ago than this are skipped unless includeOlder is set. */
|
|
16
|
+
const RECENT_COURSE_MS = 365 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const inaccessible = (error) => error instanceof ApiError && (error.status === 403 || error.status === 404);
|
|
18
|
+
const text = (value) => value?.Text?.trim() || undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Brightspace shows a category on the "Available Groups" page while its
|
|
21
|
+
* sign-up window is open; a missing start or expiry date leaves that side open.
|
|
22
|
+
*/
|
|
23
|
+
function signUpOpen(category, now) {
|
|
24
|
+
if (!SELF_ENROLLMENT_STYLES.has(category.EnrollmentStyle))
|
|
25
|
+
return false;
|
|
26
|
+
const start = category.SelfEnrollmentStartDate ? Date.parse(category.SelfEnrollmentStartDate) : -Infinity;
|
|
27
|
+
const end = category.SelfEnrollmentExpiryDate ? Date.parse(category.SelfEnrollmentExpiryDate) : Infinity;
|
|
28
|
+
return start <= now && now <= end;
|
|
29
|
+
}
|
|
30
|
+
async function fetchCourseGroups(apiClient, course, myId, now) {
|
|
31
|
+
const result = { courseId: course.id, courseName: course.name, categories: [], alreadyInGroup: [] };
|
|
32
|
+
let categories;
|
|
33
|
+
try {
|
|
34
|
+
categories = await apiClient.get(apiClient.lp(`/${course.id}/groupcategories/`), { ttl: DEFAULT_CACHE_TTLS.roster });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (inaccessible(error))
|
|
38
|
+
return result;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const unread = [];
|
|
42
|
+
for (const category of categories.filter((c) => signUpOpen(c, now))) {
|
|
43
|
+
let groups;
|
|
44
|
+
try {
|
|
45
|
+
groups = await apiClient.get(apiClient.lp(`/${course.id}/groupcategories/${category.GroupCategoryId}/groups/`), { ttl: DEFAULT_CACHE_TTLS.roster });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (inaccessible(error)) {
|
|
49
|
+
unread.push(category.Name);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
// Self-enrollment allows one group per category, as on the Available Groups page.
|
|
55
|
+
const mine = groups.find((g) => g.Enrollments?.some((id) => Number(id) === myId));
|
|
56
|
+
if (mine) {
|
|
57
|
+
result.alreadyInGroup.push({ categoryId: category.GroupCategoryId, name: category.Name, groupName: mine.Name });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const max = category.MaxUsersPerGroup ?? null;
|
|
61
|
+
const showDescriptions = category.DescriptionsVisibleToEnrolees !== false;
|
|
62
|
+
const open = groups
|
|
63
|
+
.map((g) => {
|
|
64
|
+
const members = g.Enrollments?.length ?? 0;
|
|
65
|
+
const description = showDescriptions ? text(g.Description) : undefined;
|
|
66
|
+
return {
|
|
67
|
+
groupId: g.GroupId,
|
|
68
|
+
name: g.Name,
|
|
69
|
+
members,
|
|
70
|
+
spotsLeft: max === null ? null : Math.max(0, max - members),
|
|
71
|
+
...(description ? { description } : {}),
|
|
72
|
+
};
|
|
73
|
+
})
|
|
74
|
+
.filter((g) => g.spotsLeft === null || g.spotsLeft > 0);
|
|
75
|
+
if (open.length === 0)
|
|
76
|
+
continue;
|
|
77
|
+
const description = text(category.Description);
|
|
78
|
+
result.categories.push({
|
|
79
|
+
categoryId: category.GroupCategoryId,
|
|
80
|
+
name: category.Name,
|
|
81
|
+
...(description ? { description } : {}),
|
|
82
|
+
signUpCloses: category.SelfEnrollmentExpiryDate ?? null,
|
|
83
|
+
maxPerGroup: max,
|
|
84
|
+
groups: open,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (unread.length > 0)
|
|
88
|
+
result.unreadCategories = unread;
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Register get_available_groups tool
|
|
93
|
+
*/
|
|
94
|
+
export function registerGetAvailableGroups(server, apiClient, config) {
|
|
95
|
+
server.registerTool("get_available_groups", {
|
|
96
|
+
title: "Get Available Groups",
|
|
97
|
+
annotations: { readOnlyHint: true },
|
|
98
|
+
description: "List the groups the current user can still join themselves (self-enrollment), with spots left " +
|
|
99
|
+
"and when sign-up closes. Use this when a student asks which project or lab groups are open. " +
|
|
100
|
+
"This tool only reads; joining happens in Brightspace under Groups > Available Groups.",
|
|
101
|
+
inputSchema: GetAvailableGroupsSchema,
|
|
102
|
+
}, async (args) => {
|
|
103
|
+
try {
|
|
104
|
+
log("DEBUG", "get_available_groups tool called", { args });
|
|
105
|
+
const { courseId, includeOlder } = GetAvailableGroupsSchema.parse(args);
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const myId = await fetchCurrentUserId(apiClient);
|
|
108
|
+
const enrolled = await resolveCourses(apiClient, config, courseId);
|
|
109
|
+
// Some schools give courses a start date but never an end date and never
|
|
110
|
+
// close them, so every past course still counts as active and its old
|
|
111
|
+
// sign-ups without an expiry still look open. The start date is the only
|
|
112
|
+
// signal of which courses are current.
|
|
113
|
+
const courses = courseId || includeOlder ? enrolled : enrolled.filter((course) => {
|
|
114
|
+
const start = course.startDate ? Date.parse(course.startDate) : NaN;
|
|
115
|
+
return Number.isFinite(start) && start >= now - RECENT_COURSE_MS;
|
|
116
|
+
});
|
|
117
|
+
const olderSkipped = enrolled.length - courses.length;
|
|
118
|
+
const results = await Promise.allSettled(courses.map((course) => fetchCourseGroups(apiClient, course, myId, now)));
|
|
119
|
+
const pendingCourseIds = [];
|
|
120
|
+
const failedCourseIds = [];
|
|
121
|
+
let firstAuthError = null;
|
|
122
|
+
const found = results.flatMap((result, i) => {
|
|
123
|
+
if (result.status === "fulfilled")
|
|
124
|
+
return [result.value];
|
|
125
|
+
if (isAuthUnavailable(result.reason)) {
|
|
126
|
+
if (courseId)
|
|
127
|
+
throw result.reason;
|
|
128
|
+
pendingCourseIds.push(courses[i].id);
|
|
129
|
+
firstAuthError ??= result.reason;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (courseId)
|
|
133
|
+
throw result.reason;
|
|
134
|
+
failedCourseIds.push(courses[i].id);
|
|
135
|
+
log("DEBUG", "get_available_groups: skipping course after fetch failure", result.reason);
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
});
|
|
139
|
+
// A course with nothing open is left out so the list stays about choices.
|
|
140
|
+
const withChoices = found.filter((c) => c.categories.length > 0 || c.alreadyInGroup.length > 0 || c.unreadCategories);
|
|
141
|
+
const openGroups = withChoices.reduce((n, c) => n + c.categories.reduce((m, cat) => m + cat.groups.length, 0), 0);
|
|
142
|
+
log("INFO", `get_available_groups: ${openGroups} open groups across ${courses.length} course(s)`);
|
|
143
|
+
const notes = [];
|
|
144
|
+
if (withChoices.length === 0)
|
|
145
|
+
notes.push("No group sign-ups are open in these courses right now.");
|
|
146
|
+
if (olderSkipped > 0) {
|
|
147
|
+
notes.push(`Checked the courses that started in the last 12 months; pass includeOlder: true to also check the other ${olderSkipped}.`);
|
|
148
|
+
}
|
|
149
|
+
if (failedCourseIds.length > 0) {
|
|
150
|
+
notes.push(`Couldn't read groups for course(s) ${failedCourseIds.join(", ")}, so open sign-ups there aren't listed.`);
|
|
151
|
+
}
|
|
152
|
+
const data = {
|
|
153
|
+
coursesChecked: courses.length,
|
|
154
|
+
courses: withChoices,
|
|
155
|
+
...(notes.length > 0 ? { note: notes.join(" ") } : {}),
|
|
156
|
+
...(pendingCourseIds.length > 0 ? { authPending: true, unavailableCourseIds: pendingCourseIds } : {}),
|
|
157
|
+
};
|
|
158
|
+
return pendingCourseIds.length > 0
|
|
159
|
+
? toolResponseWithNotice(data, authPendingNotice(firstAuthError))
|
|
160
|
+
: toolResponse(data);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return sanitizeError(error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { GetCourseContentSchema } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
10
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
@@ -44,18 +44,37 @@ export async function fetchRootContent(apiClient, courseId) {
|
|
|
44
44
|
return apiClient.get(apiClient.le(courseId, '/content/root/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
* Fetch a single module's structure, or
|
|
48
|
-
*
|
|
49
|
-
* whatever structure the parent listing already embedded.
|
|
47
|
+
* Fetch a single module's structure, or say why it couldn't be read (locked
|
|
48
|
+
* module, transient error, an answer that is not a list) so the caller can
|
|
49
|
+
* fall back to whatever structure the parent listing already embedded. A
|
|
50
|
+
* pending or expired sign-in is rethrown: it fails every module the same way,
|
|
51
|
+
* and must fail the call so the user is told to sign in, not shown a course
|
|
52
|
+
* whose every module looks empty.
|
|
50
53
|
*/
|
|
51
54
|
export async function fetchModuleStructure(apiClient, courseId, moduleId) {
|
|
55
|
+
let structure;
|
|
52
56
|
try {
|
|
53
|
-
|
|
57
|
+
structure = await apiClient.get(apiClient.le(courseId, `/content/modules/${moduleId}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
54
58
|
}
|
|
55
|
-
catch (
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (isAuthUnavailable(error))
|
|
61
|
+
throw error;
|
|
62
|
+
log('DEBUG', `Failed to fetch children for module ${moduleId}: falling back to the embedded structure`, error);
|
|
63
|
+
const status = typeof error?.status === 'number' ? error.status : null;
|
|
64
|
+
return {
|
|
65
|
+
status,
|
|
66
|
+
reason: status === 403
|
|
67
|
+
? "Brightspace refused this account's request for this module's contents. It may hold items; open the module in a browser to see what this account can read."
|
|
68
|
+
: `Couldn't read this module's contents${status !== null ? ` (Brightspace answered ${status})` : ''}. It may hold items; try again.`,
|
|
69
|
+
};
|
|
58
70
|
}
|
|
71
|
+
if (Array.isArray(structure))
|
|
72
|
+
return structure;
|
|
73
|
+
log('DEBUG', `Module ${moduleId} structure was not a list: falling back to the embedded structure`);
|
|
74
|
+
return {
|
|
75
|
+
status: null,
|
|
76
|
+
reason: "Brightspace answered with something other than a list of this module's contents. It may hold items; try again.",
|
|
77
|
+
};
|
|
59
78
|
}
|
|
60
79
|
/**
|
|
61
80
|
* How deep the tree builder will descend when the caller names no maxDepth.
|
|
@@ -92,6 +111,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
92
111
|
if (item.Type === 0) {
|
|
93
112
|
// Module: fetch children recursively (unless the depth limit is reached)
|
|
94
113
|
let processedChildren = [];
|
|
114
|
+
let childrenUnavailable;
|
|
95
115
|
if (currentDepth < depthLimit) {
|
|
96
116
|
// The parent listing already embeds this module's immediate children
|
|
97
117
|
// in Structure. The dedicated endpoint is still asked first because it
|
|
@@ -100,17 +120,30 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
100
120
|
// what the parent already handed us. Falling through to an empty list
|
|
101
121
|
// made a locked or erroring module look like an empty one, and under a
|
|
102
122
|
// typeFilter it dropped the module from the tree with no trace.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
// When neither copy is readable, the module's children are reported
|
|
124
|
+
// as null with a reason, never as [], so it can't pass for an empty
|
|
125
|
+
// module, and the module is kept under any typeFilter because nothing
|
|
126
|
+
// says it holds no matching items.
|
|
127
|
+
const structure = await fetchModuleStructure(apiClient, courseId, item.Id);
|
|
128
|
+
let children = null;
|
|
129
|
+
if (Array.isArray(structure)) {
|
|
130
|
+
children = structure;
|
|
131
|
+
}
|
|
132
|
+
else if (Array.isArray(item.Structure)) {
|
|
133
|
+
children = item.Structure;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
childrenUnavailable = structure;
|
|
137
|
+
}
|
|
138
|
+
if (children) {
|
|
139
|
+
processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1, availability, now);
|
|
106
140
|
}
|
|
107
|
-
processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1, availability, now);
|
|
108
141
|
}
|
|
109
142
|
else if (currentDepth >= MAX_CONTENT_DEPTH) {
|
|
110
143
|
log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
|
|
111
144
|
}
|
|
112
145
|
// Only include module if it has matching children (or filter is 'all')
|
|
113
|
-
if (typeFilter === 'all' || processedChildren.length > 0) {
|
|
146
|
+
if (typeFilter === 'all' || processedChildren.length > 0 || childrenUnavailable) {
|
|
114
147
|
const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId, apiClient.origin) : [];
|
|
115
148
|
tree.push({
|
|
116
149
|
type: 'module',
|
|
@@ -125,7 +158,8 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
125
158
|
...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
|
|
126
159
|
...availabilityFields(availability),
|
|
127
160
|
lastModified: item.LastModifiedDate ?? null,
|
|
128
|
-
|
|
161
|
+
...(childrenUnavailable ? { childrenUnavailable } : {}),
|
|
162
|
+
children: childrenUnavailable ? null : processedChildren,
|
|
129
163
|
});
|
|
130
164
|
}
|
|
131
165
|
}
|
|
@@ -186,6 +220,12 @@ function filterTreeByModifiedSince(tree, cutoff) {
|
|
|
186
220
|
}
|
|
187
221
|
}
|
|
188
222
|
else if (item.type === 'module') {
|
|
223
|
+
// A module whose children couldn't be read stays in, children null:
|
|
224
|
+
// nothing says none of them changed since the cutoff.
|
|
225
|
+
if (item.childrenUnavailable) {
|
|
226
|
+
result.push(item);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
189
229
|
const filteredChildren = filterTreeByModifiedSince(item.children ?? [], cutoff);
|
|
190
230
|
const moduleMatches = matchesModifiedSince(item.lastModified, cutoff);
|
|
191
231
|
if (moduleMatches || filteredChildren.length > 0) {
|
|
@@ -225,6 +265,21 @@ function countModules(tree) {
|
|
|
225
265
|
}
|
|
226
266
|
return count;
|
|
227
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Count modules whose children couldn't be read (for the notice).
|
|
270
|
+
*/
|
|
271
|
+
export function countUnreadableModules(tree) {
|
|
272
|
+
let count = 0;
|
|
273
|
+
for (const item of tree) {
|
|
274
|
+
if (item.type === 'module') {
|
|
275
|
+
if (item.childrenUnavailable)
|
|
276
|
+
count++;
|
|
277
|
+
if (item.children)
|
|
278
|
+
count += countUnreadableModules(item.children);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return count;
|
|
282
|
+
}
|
|
228
283
|
/**
|
|
229
284
|
* Register get_course_content tool
|
|
230
285
|
*/
|
|
@@ -276,7 +331,8 @@ export function registerGetCourseContent(server, apiClient) {
|
|
|
276
331
|
}
|
|
277
332
|
const topicCount = countTopics(contentTree);
|
|
278
333
|
const moduleCount = countModules(contentTree);
|
|
279
|
-
|
|
334
|
+
const unreadable = countUnreadableModules(contentTree);
|
|
335
|
+
log("INFO", `get_course_content: Retrieved ${moduleCount} modules and ${topicCount} topics for course ${courseId} (filter: ${typeFilter})${unreadable > 0 ? `, ${unreadable} module(s) unreadable` : ""}`);
|
|
280
336
|
return toolResponse({
|
|
281
337
|
courseId,
|
|
282
338
|
typeFilter,
|
|
@@ -284,6 +340,13 @@ export function registerGetCourseContent(server, apiClient) {
|
|
|
284
340
|
topicCount,
|
|
285
341
|
moduleCount,
|
|
286
342
|
...(modifiedSince ? { modifiedSince, returned: topicCount, filteredOut } : {}),
|
|
343
|
+
...(unreadable > 0
|
|
344
|
+
? {
|
|
345
|
+
notice: `The contents of ${unreadable} module(s) couldn't be read; they have children null and a ` +
|
|
346
|
+
"childrenUnavailable reason. topicCount leaves out whatever they hold. Do not report " +
|
|
347
|
+
"those modules as empty.",
|
|
348
|
+
}
|
|
349
|
+
: {}),
|
|
287
350
|
});
|
|
288
351
|
}
|
|
289
352
|
catch (error) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { GetDiscussionsSchema } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { discussionUrl } from "../utils/deep-links.js";
|
|
@@ -62,6 +62,21 @@ export function registerGetDiscussions(server, apiClient, baseUrl) {
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Describe a failed per-item read, or rethrow it when the session itself is
|
|
67
|
+
* unusable. A pending or expired sign-in fails every item the same way, and
|
|
68
|
+
* must fail the call so the user is told to sign in, not shown a course
|
|
69
|
+
* whose every forum looks empty.
|
|
70
|
+
*/
|
|
71
|
+
function subListUnavailable(error, operation, what) {
|
|
72
|
+
if (isAuthUnavailable(error))
|
|
73
|
+
throw error;
|
|
74
|
+
const status = typeof error?.status === "number" ? error.status : null;
|
|
75
|
+
const reason = status === 403
|
|
76
|
+
? `Brightspace refused this account's request for the ${what}. They may exist; open the discussion in a browser to see what this account can read.`
|
|
77
|
+
: `Couldn't read the ${what}${status !== null ? ` (Brightspace answered ${status})` : ""}. They may exist; try again.`;
|
|
78
|
+
return { operation, status, reason };
|
|
79
|
+
}
|
|
65
80
|
/**
|
|
66
81
|
* Get all forums for a course with their topics (no posts).
|
|
67
82
|
*/
|
|
@@ -69,18 +84,17 @@ async function getForumsOverview(apiClient, courseId) {
|
|
|
69
84
|
const forums = await fetchForums(apiClient, courseId);
|
|
70
85
|
const result = [];
|
|
71
86
|
for (const forum of forums) {
|
|
72
|
-
// Fetch topics for each forum
|
|
73
|
-
|
|
87
|
+
// Fetch topics for each forum. A failed read is reported as null with a
|
|
88
|
+
// reason, never as [], so it can't pass for a forum with no topics.
|
|
89
|
+
let topics;
|
|
90
|
+
let topicsUnavailable;
|
|
74
91
|
try {
|
|
75
92
|
topics = await fetchForumTopics(apiClient, courseId, forum.ForumId);
|
|
76
93
|
}
|
|
77
94
|
catch (error) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
else {
|
|
82
|
-
log("DEBUG", `Failed to fetch topics for forum ${forum.ForumId}`, error);
|
|
83
|
-
}
|
|
95
|
+
topicsUnavailable = subListUnavailable(error, "listTopics", "topics");
|
|
96
|
+
log("INFO", `get_discussions: Topics for forum ${forum.ForumId} unavailable`, error);
|
|
97
|
+
topics = null;
|
|
84
98
|
}
|
|
85
99
|
result.push({
|
|
86
100
|
forumId: forum.ForumId,
|
|
@@ -88,7 +102,8 @@ async function getForumsOverview(apiClient, courseId) {
|
|
|
88
102
|
description: forum.Description?.Text ?? null,
|
|
89
103
|
isLocked: forum.IsLocked,
|
|
90
104
|
isHidden: forum.IsHidden,
|
|
91
|
-
|
|
105
|
+
...(topicsUnavailable ? { topicsUnavailable } : {}),
|
|
106
|
+
topics: topics?.map((t) => ({
|
|
92
107
|
topicId: t.TopicId,
|
|
93
108
|
forumId: t.ForumId,
|
|
94
109
|
name: t.Name,
|
|
@@ -98,7 +113,7 @@ async function getForumsOverview(apiClient, courseId) {
|
|
|
98
113
|
isHidden: t.IsHidden,
|
|
99
114
|
mustPostToParticipate: t.MustPostToParticipate,
|
|
100
115
|
scoreOutOf: t.ScoreOutOf,
|
|
101
|
-
})),
|
|
116
|
+
})) ?? null,
|
|
102
117
|
});
|
|
103
118
|
}
|
|
104
119
|
log("INFO", `get_discussions: Retrieved ${forums.length} forums for course ${courseId}`);
|
|
@@ -125,22 +140,21 @@ async function getForumDetail(apiClient, courseId, forumId) {
|
|
|
125
140
|
// Fetch posts for each topic
|
|
126
141
|
const topicsWithPosts = [];
|
|
127
142
|
for (const topic of topics) {
|
|
128
|
-
|
|
143
|
+
// A failed posts read is reported as null with a reason, never as an
|
|
144
|
+
// empty thread, matching the single-topic view.
|
|
145
|
+
let formatted;
|
|
146
|
+
let postsUnavailable;
|
|
129
147
|
try {
|
|
130
148
|
const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topic.TopicId}/posts/`);
|
|
131
|
-
|
|
149
|
+
formatted = formatPosts(await apiClient.get(postsPath, {
|
|
132
150
|
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
133
|
-
});
|
|
151
|
+
}));
|
|
134
152
|
}
|
|
135
153
|
catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
else {
|
|
140
|
-
log("DEBUG", `Failed to fetch posts for topic ${topic.TopicId}`, error);
|
|
141
|
-
}
|
|
154
|
+
postsUnavailable = subListUnavailable(error, "listPosts", "posts");
|
|
155
|
+
log("INFO", `get_discussions: Posts for topic ${topic.TopicId} unavailable`, error);
|
|
156
|
+
formatted = null;
|
|
142
157
|
}
|
|
143
|
-
const formatted = formatPosts(posts);
|
|
144
158
|
topicsWithPosts.push({
|
|
145
159
|
topicId: topic.TopicId,
|
|
146
160
|
name: topic.Name,
|
|
@@ -151,8 +165,9 @@ async function getForumDetail(apiClient, courseId, forumId) {
|
|
|
151
165
|
isLocked: topic.IsLocked,
|
|
152
166
|
mustPostToParticipate: topic.MustPostToParticipate,
|
|
153
167
|
scoreOutOf: topic.ScoreOutOf,
|
|
154
|
-
postCount: formatted
|
|
168
|
+
postCount: formatted?.length ?? null,
|
|
155
169
|
posts: formatted,
|
|
170
|
+
...(postsUnavailable ? { postsUnavailable } : {}),
|
|
156
171
|
});
|
|
157
172
|
}
|
|
158
173
|
log("INFO", `get_discussions: Retrieved forum ${forumId} with ${topics.length} topics for course ${courseId}`);
|
|
@@ -5,10 +5,28 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ApiError } from "../api/errors.js";
|
|
7
7
|
import { GetMyGroupsSchema } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { fetchClasslistUsers } from "./get-roster.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { fetchMyGroupMemberships } from "./group-membership.js";
|
|
12
|
+
import { decodeEntities } from "../utils/html-attributes.js";
|
|
13
|
+
/**
|
|
14
|
+
* Names on a group's "View Members" page (group_member_list.d2l), or null when
|
|
15
|
+
* the page doesn't have the member table. Each data row holds one name in a
|
|
16
|
+
* single cell; the header row is the only one with a class.
|
|
17
|
+
*/
|
|
18
|
+
export function memberNamesFromPage(html) {
|
|
19
|
+
const table = /<table\b[^>]*\bd2l-grid\b[^>]*>([\s\S]*?)<\/table>/i.exec(html);
|
|
20
|
+
if (!table)
|
|
21
|
+
return null;
|
|
22
|
+
const names = [];
|
|
23
|
+
for (const row of table[1].matchAll(/<tr>\s*<td\b[^>]*>([\s\S]*?)<\/td>\s*<\/tr>/gi)) {
|
|
24
|
+
const name = decodeEntities(row[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
|
|
25
|
+
if (name)
|
|
26
|
+
names.push(name);
|
|
27
|
+
}
|
|
28
|
+
return names;
|
|
29
|
+
}
|
|
12
30
|
/** True when a group/category endpoint simply isn't there for this course or user. */
|
|
13
31
|
function isMissingOrForbidden(error) {
|
|
14
32
|
return error instanceof ApiError && (error.status === 403 || error.status === 404);
|
|
@@ -76,6 +94,25 @@ export function registerGetMyGroups(server, apiClient) {
|
|
|
76
94
|
throw error;
|
|
77
95
|
}
|
|
78
96
|
}
|
|
97
|
+
// Students who can't read the classlist can still open each of their
|
|
98
|
+
// groups' "View Members" page, so read the names from there. The page
|
|
99
|
+
// carries no user ids, so the names stay a separate list.
|
|
100
|
+
const pageNames = new Map();
|
|
101
|
+
if (namesUnavailable) {
|
|
102
|
+
for (const { group } of matches) {
|
|
103
|
+
try {
|
|
104
|
+
const html = await apiClient.getPage(`/d2l/lms/group/group_member_list.d2l?ou=${courseId}&groupId=${group.GroupId}`);
|
|
105
|
+
const names = html === null ? null : memberNamesFromPage(html);
|
|
106
|
+
if (names)
|
|
107
|
+
pageNames.set(group.GroupId, names);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (isAuthUnavailable(error))
|
|
111
|
+
throw error;
|
|
112
|
+
log("DEBUG", `get_my_groups: Couldn't read the member page for group ${group.GroupId}`, error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
79
116
|
const groups = matches.map(({ category, group }) => {
|
|
80
117
|
const enrollments = group.Enrollments ?? [];
|
|
81
118
|
const members = enrollments.map((userId) => ({
|
|
@@ -89,15 +126,19 @@ export function registerGetMyGroups(server, apiClient) {
|
|
|
89
126
|
groupName: group.Name,
|
|
90
127
|
...(group.Code ? { groupCode: group.Code } : {}),
|
|
91
128
|
members,
|
|
129
|
+
...(pageNames.has(group.GroupId) ? { memberNames: pageNames.get(group.GroupId) } : {}),
|
|
92
130
|
};
|
|
93
131
|
});
|
|
94
132
|
log("INFO", `get_my_groups: Found ${groups.length} groups for course ${courseId}`);
|
|
95
133
|
// Null names are only meaningful with a reason attached: without one,
|
|
96
134
|
// a caller can't tell "the classlist couldn't be read" from "these
|
|
97
135
|
// members have no names", and may report the group as anonymous.
|
|
98
|
-
const namesNote = namesUnavailable
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
const namesNote = !namesUnavailable ? undefined
|
|
137
|
+
: pageNames.size === matches.length
|
|
138
|
+
? "Couldn't read this course's classlist, so member names are null; memberNames lists each group's " +
|
|
139
|
+
"members from its member page, which doesn't say which name belongs to which user id."
|
|
140
|
+
: "Couldn't read this course's classlist, so member names are null; only user ids are listed" +
|
|
141
|
+
(pageNames.size > 0 ? ", except where memberNames lists a group's members from its member page." : ".");
|
|
101
142
|
const note = [unreadNote, namesNote].filter(Boolean).join(" ") || undefined;
|
|
102
143
|
return toolResponse({ courseId, groups, ...(note ? { note } : {}) });
|
|
103
144
|
}
|
package/build/tools/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.
|
|
|
18
18
|
export { registerGetClasslistEmails } from "./get-classlist-emails.js";
|
|
19
19
|
export { registerGetRoster } from "./get-roster.js";
|
|
20
20
|
export { registerGetMyGroups } from "./get-my-groups.js";
|
|
21
|
+
export { registerGetAvailableGroups } from "./get-available-groups.js";
|
|
21
22
|
export { registerGetSyllabus } from "./get-syllabus.js";
|
|
22
23
|
export { registerGetDiscussions } from "./get-discussions.js";
|
|
23
24
|
export { registerGetVideoTranscript } from "./get-video-transcript.js";
|
|
@@ -33,7 +33,7 @@ export async function resolveCourses(apiClient, config, courseId) {
|
|
|
33
33
|
}
|
|
34
34
|
if (courseId) {
|
|
35
35
|
const match = items.find((item) => item.OrgUnit.Id === courseId);
|
|
36
|
-
return [{ id: courseId, name: match?.OrgUnit.Name ?? null }];
|
|
36
|
+
return [{ id: courseId, name: match?.OrgUnit.Name ?? null, startDate: match?.Access.StartDate ?? null }];
|
|
37
37
|
}
|
|
38
38
|
const filtered = applyCourseFilter(items.map((item) => ({
|
|
39
39
|
id: item.OrgUnit.Id,
|
|
@@ -44,5 +44,5 @@ export async function resolveCourses(apiClient, config, courseId) {
|
|
|
44
44
|
startDate: item.Access.StartDate,
|
|
45
45
|
endDate: item.Access.EndDate,
|
|
46
46
|
})), config.courseFilter);
|
|
47
|
-
return filtered.map((course) => ({ id: course.id, name: course.name }));
|
|
47
|
+
return filtered.map((course) => ({ id: course.id, name: course.name, startDate: course.startDate }));
|
|
48
48
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -160,6 +160,12 @@ export const GetMyGroupsSchema = z.object({
|
|
|
160
160
|
courseId: z.coerce.number().int().positive()
|
|
161
161
|
.describe("Course ID to list the current user's project/discussion groups for."),
|
|
162
162
|
});
|
|
163
|
+
export const GetAvailableGroupsSchema = z.object({
|
|
164
|
+
courseId: z.coerce.number().int().positive().optional()
|
|
165
|
+
.describe("Course ID to list joinable groups for. If omitted, checks courses that started in the last 12 months."),
|
|
166
|
+
includeOlder: z.boolean().default(false)
|
|
167
|
+
.describe("Without courseId, also check older and undated courses. Schools that never close a course keep years of finished courses whose sign-ups still look open."),
|
|
168
|
+
});
|
|
163
169
|
export const GetServerInfoSchema = z.object({});
|
|
164
170
|
export const SignOutSchema = z.object({});
|
|
165
171
|
export const SignInSchema = z.object({
|
|
@@ -7,7 +7,7 @@ import { ApiError } from "../api/index.js";
|
|
|
7
7
|
import { SearchCourseSchema } from "./schemas.js";
|
|
8
8
|
import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { log } from "../utils/logger.js";
|
|
10
|
-
import { fetchRootContent, buildContentTree } from "./get-course-content.js";
|
|
10
|
+
import { fetchRootContent, buildContentTree, countUnreadableModules } from "./get-course-content.js";
|
|
11
11
|
import { fetchCourseNews, isPublishedNewsItem, mapNewsItem } from "./get-announcements.js";
|
|
12
12
|
import { fetchForums, fetchForumTopics } from "./get-discussions.js";
|
|
13
13
|
/** A query term matched in the title outranks the same term only in the body. */
|
|
@@ -186,6 +186,10 @@ export function registerSearchCourse(server, apiClient) {
|
|
|
186
186
|
const rootModules = await fetchRootContent(apiClient, courseId);
|
|
187
187
|
const tree = await buildContentTree(apiClient, courseId, rootModules, new Map(), "all");
|
|
188
188
|
entries.push(...flattenContentTree(tree));
|
|
189
|
+
const unreadable = countUnreadableModules(tree);
|
|
190
|
+
if (unreadable > 0) {
|
|
191
|
+
notes.push(`content inside ${unreadable} module(s) whose contents couldn't be read`);
|
|
192
|
+
}
|
|
189
193
|
}
|
|
190
194
|
catch (error) {
|
|
191
195
|
// A pending or refused sign-in is not a source that failed: the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.36",
|
|
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",
|