brightspace-mcp-server 3.10.13 → 3.10.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.
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) |
@@ -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) {
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.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",