brightspace-mcp-server 3.13.12 → 3.13.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 +2 -2
- package/build/tools/get-announcements.js +17 -2
- package/build/tools/get-assignments.js +36 -1
- package/build/tools/get-dropbox-feedback.js +25 -8
- package/build/tools/group-membership.js +7 -2
- package/build/tools/schemas.js +4 -4
- package/build/utils/html-converter.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ 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" · "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) |
|
|
68
|
+
| Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "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?" |
|
|
@@ -76,7 +76,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
76
76
|
| Discussions | "Summarize the latest posts in the final project thread" |
|
|
77
77
|
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
|
|
78
78
|
| Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
|
|
79
|
-
| For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students
|
|
79
|
+
| For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students get a clear "instructor access required" note for these, but can read their own released feedback, below) |
|
|
80
80
|
| Signing out | "Sign me out of Brightspace" (your AI app may ask you to confirm first; details under [When it asks you to sign in](#when-it-asks-you-to-sign-in)) |
|
|
81
81
|
|
|
82
82
|
## Prompts
|
|
@@ -10,6 +10,7 @@ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable,
|
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
13
|
+
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
13
14
|
function pageInfo(offset, count, returned, total, unavailableCourseIds = []) {
|
|
14
15
|
const nextOffset = total !== null && offset + returned < total ? offset + returned : null;
|
|
15
16
|
return { offset, count, returned, total, nextOffset, truncated: nextOffset !== null,
|
|
@@ -73,6 +74,20 @@ export async function fetchCourseNews(apiClient, courseId) {
|
|
|
73
74
|
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
74
75
|
});
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The announcement text a reader should see. D2L's RichText.Text is only "the
|
|
79
|
+
* plaintext form of the text": it carries no link targets, and the API makes
|
|
80
|
+
* no promise that it is present or equal to Html. The Html is the post as the
|
|
81
|
+
* instructor wrote it, so it leads (as markdown, keeping links, lists and
|
|
82
|
+
* tables); Text is the fallback for a text-only post or Html with nothing
|
|
83
|
+
* readable in it.
|
|
84
|
+
*/
|
|
85
|
+
export function newsBodyText(body) {
|
|
86
|
+
if (!body)
|
|
87
|
+
return "";
|
|
88
|
+
const markdown = body.Html ? convertHtmlToMarkdown(body.Html).markdown.trim() : "";
|
|
89
|
+
return markdown || body.Text || "";
|
|
90
|
+
}
|
|
76
91
|
/**
|
|
77
92
|
* Map a raw D2L news item to a clean announcement object.
|
|
78
93
|
*/
|
|
@@ -85,7 +100,7 @@ export function mapNewsItem(item) {
|
|
|
85
100
|
return {
|
|
86
101
|
id: item.Id,
|
|
87
102
|
title: item.Title,
|
|
88
|
-
body: item.Body
|
|
103
|
+
body: newsBodyText(item.Body),
|
|
89
104
|
createdBy: item.CreatedBy?.DisplayName ?? "Unknown",
|
|
90
105
|
date: effectiveDate(item),
|
|
91
106
|
isPinned: item.IsPinned,
|
|
@@ -100,7 +115,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
100
115
|
server.registerTool("get_announcements", {
|
|
101
116
|
title: "Get Announcements",
|
|
102
117
|
annotations: { readOnlyHint: true },
|
|
103
|
-
description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
|
|
118
|
+
description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Each body is the full announcement text as markdown (links kept); pagination.truncated only means more announcements remain, never that a body was cut. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
|
|
104
119
|
inputSchema: GetAnnouncementsSchema,
|
|
105
120
|
}, async (args) => {
|
|
106
121
|
try {
|
|
@@ -14,6 +14,7 @@ import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
|
14
14
|
import { assignmentLinkResolver } from "./assignment-links.js";
|
|
15
15
|
import { dueIn } from "../utils/due-in.js";
|
|
16
16
|
import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
|
|
17
|
+
import { fetchCurrentUserId } from "./group-membership.js";
|
|
17
18
|
import { toOutputAssessment } from "./get-assignment-rubric.js";
|
|
18
19
|
/** The display HTML of a quiz rich-text field, nested shape or flat. */
|
|
19
20
|
function richTextHtml(field) {
|
|
@@ -144,9 +145,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
144
145
|
// Adapted from JoshuaMontclair/brightspace-mcp-server (MIT).
|
|
145
146
|
let submissions = [];
|
|
146
147
|
let submissionsKnown = true;
|
|
148
|
+
let embeddedFeedback = null;
|
|
147
149
|
try {
|
|
148
150
|
const submissionsRaw = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/submissions/mysubmissions/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
149
|
-
|
|
151
|
+
const entries = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
|
|
152
|
+
// D2L documents EntityDropbox ({ Feedback, Submissions: [...] }) for this
|
|
153
|
+
// route; older tenants answer with the flat submissions. Accept both.
|
|
154
|
+
embeddedFeedback = entries.find((e) => e.Feedback)?.Feedback ?? null;
|
|
155
|
+
submissions = entries.flatMap((e) => (Array.isArray(e.Submissions) ? e.Submissions : [e]));
|
|
150
156
|
}
|
|
151
157
|
catch (error) {
|
|
152
158
|
if (isAuthUnavailable(error))
|
|
@@ -175,6 +181,34 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
175
181
|
log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
|
|
176
182
|
}
|
|
177
183
|
}
|
|
184
|
+
// myFeedback is not one of D2L's documented entity types, so when it has
|
|
185
|
+
// nothing, read the student's own released feedback from the documented
|
|
186
|
+
// routes: the Feedback embedded in mysubmissions (already fetched), then
|
|
187
|
+
// feedback/user/{me}. Draft (IsGraded: false) feedback is not released.
|
|
188
|
+
let feedbackSource = feedback ? "myFeedback" : null;
|
|
189
|
+
if (!feedback && submissions.length > 0) {
|
|
190
|
+
if (embeddedFeedback && embeddedFeedback.IsGraded !== false) {
|
|
191
|
+
feedback = embeddedFeedback;
|
|
192
|
+
feedbackSource = "submission";
|
|
193
|
+
}
|
|
194
|
+
else if (folder.GroupTypeId === null || folder.GroupTypeId === undefined) {
|
|
195
|
+
try {
|
|
196
|
+
const myId = await fetchCurrentUserId(apiClient);
|
|
197
|
+
const own = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/user/${myId}`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
198
|
+
if (own && own.IsGraded !== false) {
|
|
199
|
+
feedback = own;
|
|
200
|
+
feedbackSource = "user";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (isAuthUnavailable(error))
|
|
205
|
+
throw error;
|
|
206
|
+
log("DEBUG", `Own-feedback fallback failed for folder ${folder.Id}`, error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (feedback)
|
|
210
|
+
feedbackStatus = "retrieved";
|
|
211
|
+
}
|
|
178
212
|
// Instructor-provided URL links attached to the folder. Omitted
|
|
179
213
|
// entirely (rather than an empty array) when the folder has none, to
|
|
180
214
|
// keep the common case's shape exactly as it was.
|
|
@@ -251,6 +285,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
251
285
|
}
|
|
252
286
|
: null,
|
|
253
287
|
feedbackStatus,
|
|
288
|
+
...(feedbackSource ? { feedbackSource } : {}),
|
|
254
289
|
feedbackCommentsStatus: commentsRetrieved ? "retrieved" : "unavailable",
|
|
255
290
|
feedbackUrl: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
|
|
256
291
|
...(commentsRetrieved ? {} : { feedbackStatusNote: FEEDBACK_UNAVAILABLE_NOTE }),
|
|
@@ -8,6 +8,7 @@ import { GetDropboxFeedbackSchema } from "./schemas.js";
|
|
|
8
8
|
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
9
9
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
|
+
import { fetchCurrentUserId } from "./group-membership.js";
|
|
11
12
|
/**
|
|
12
13
|
* Register get_dropbox_feedback tool.
|
|
13
14
|
*/
|
|
@@ -15,15 +16,26 @@ export function registerGetDropboxFeedback(server, apiClient) {
|
|
|
15
16
|
server.registerTool("get_dropbox_feedback", {
|
|
16
17
|
title: "Get Dropbox Feedback",
|
|
17
18
|
annotations: { readOnlyHint: true },
|
|
18
|
-
description: "Retrieve the
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
19
|
+
description: "Retrieve the feedback saved for a dropbox folder — score, graded state, feedback text, and rubric " +
|
|
20
|
+
"assessment detail. Students: omit entityType and entityId to read your OWN released feedback on an " +
|
|
21
|
+
"assignment (it is not hidden; use this when get_assignments shows feedback unavailable). " +
|
|
22
|
+
"Instructors and TAs: pass entityType and entityId to read what is already saved for any user or " +
|
|
23
|
+
"group, e.g. before drafting new feedback; reading another user's feedback requires instructor or TA " +
|
|
24
|
+
"access to the course.",
|
|
22
25
|
inputSchema: GetDropboxFeedbackSchema,
|
|
23
26
|
}, async (args) => {
|
|
24
27
|
try {
|
|
25
28
|
log("DEBUG", "get_dropbox_feedback tool called", { args });
|
|
26
|
-
const
|
|
29
|
+
const parsed = GetDropboxFeedbackSchema.parse(args);
|
|
30
|
+
const { courseId, folderId } = parsed;
|
|
31
|
+
// Omitted entity = the signed-in user's own feedback, which Brightspace
|
|
32
|
+
// lets a student read once it is released.
|
|
33
|
+
const own = parsed.entityId === undefined;
|
|
34
|
+
if (own && parsed.entityType === "group") {
|
|
35
|
+
return errorResponse("entityId is required when entityType is group.");
|
|
36
|
+
}
|
|
37
|
+
const entityType = parsed.entityType ?? "user";
|
|
38
|
+
const entityId = parsed.entityId ?? await fetchCurrentUserId(apiClient);
|
|
27
39
|
const feedbackPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/feedback/${entityType}/${entityId}`);
|
|
28
40
|
let feedback;
|
|
29
41
|
try {
|
|
@@ -31,8 +43,11 @@ export function registerGetDropboxFeedback(server, apiClient) {
|
|
|
31
43
|
}
|
|
32
44
|
catch (error) {
|
|
33
45
|
if (error instanceof ApiError && error.status === 403) {
|
|
34
|
-
return errorResponse(
|
|
35
|
-
"
|
|
46
|
+
return errorResponse(own
|
|
47
|
+
? "Brightspace refused this read of your own feedback; it has not been released yet, or " +
|
|
48
|
+
"this course does not expose it through the API. Check the assignment in Brightspace."
|
|
49
|
+
: "Instructor or TA access required for this course. Reading another user's grading " +
|
|
50
|
+
"feedback is only granted to course staff; to read your own, omit entityType and entityId.");
|
|
36
51
|
}
|
|
37
52
|
if (error instanceof ApiError && error.status === 404) {
|
|
38
53
|
return toolResponse({
|
|
@@ -41,7 +56,9 @@ export function registerGetDropboxFeedback(server, apiClient) {
|
|
|
41
56
|
entityType,
|
|
42
57
|
entityId,
|
|
43
58
|
feedback: null,
|
|
44
|
-
note:
|
|
59
|
+
note: own
|
|
60
|
+
? "No feedback found. This submission has not been graded yet, or its feedback has not been released."
|
|
61
|
+
: "No feedback found. This submission has not been graded yet.",
|
|
45
62
|
});
|
|
46
63
|
}
|
|
47
64
|
throw error;
|
|
@@ -2,12 +2,17 @@ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
|
2
2
|
import { ApiError } from "../api/errors.js";
|
|
3
3
|
const inaccessible = (error) => error instanceof ApiError
|
|
4
4
|
&& (error.status === 403 || error.status === 404);
|
|
5
|
-
/**
|
|
6
|
-
export async function
|
|
5
|
+
/** The signed-in user's id, from the cached whoami read. */
|
|
6
|
+
export async function fetchCurrentUserId(apiClient) {
|
|
7
7
|
const me = await apiClient.get(apiClient.lp("/users/whoami"), { ttl: DEFAULT_CACHE_TTLS.roster });
|
|
8
8
|
const myId = Number(me.Identifier);
|
|
9
9
|
if (!Number.isSafeInteger(myId) || myId <= 0)
|
|
10
10
|
throw new Error("Brightspace returned an invalid current user identifier.");
|
|
11
|
+
return myId;
|
|
12
|
+
}
|
|
13
|
+
/** Shared membership lookup. API cache/coalescing bounds repeat reads; no classlist is needed. */
|
|
14
|
+
export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
|
|
15
|
+
const myId = await fetchCurrentUserId(apiClient);
|
|
11
16
|
let categories;
|
|
12
17
|
try {
|
|
13
18
|
categories = categoryId === undefined
|
package/build/tools/schemas.js
CHANGED
|
@@ -196,10 +196,10 @@ export const GetDropboxFeedbackSchema = z.object({
|
|
|
196
196
|
.describe("Course ID the dropbox folder belongs to."),
|
|
197
197
|
folderId: z.coerce.number().int().positive()
|
|
198
198
|
.describe("Dropbox folder ID."),
|
|
199
|
-
entityType: z.enum(["user", "group"])
|
|
200
|
-
.describe("Whether entityId identifies an individual user or a group."),
|
|
201
|
-
entityId: z.coerce.number().int().positive()
|
|
202
|
-
.describe("User ID or group ID whose existing feedback to retrieve."),
|
|
199
|
+
entityType: z.enum(["user", "group"]).optional()
|
|
200
|
+
.describe("Whether entityId identifies an individual user or a group. Omit both entityType and entityId to read your own feedback."),
|
|
201
|
+
entityId: z.coerce.number().int().positive().optional()
|
|
202
|
+
.describe("User ID or group ID whose existing feedback to retrieve. Omit to read the signed-in user's own feedback."),
|
|
203
203
|
});
|
|
204
204
|
export const GetRubricsForObjectSchema = z.object({
|
|
205
205
|
courseId: z.coerce.number().int().positive()
|
|
@@ -15,6 +15,15 @@ function getTurndownService() {
|
|
|
15
15
|
turndownService = new TurndownService({
|
|
16
16
|
headingStyle: "atx",
|
|
17
17
|
codeBlockStyle: "fenced",
|
|
18
|
+
// Turndown routes any element with no text (an <object>, say) to the
|
|
19
|
+
// blank rule before the rules above are consulted, so an embedded
|
|
20
|
+
// object has to be recognised here to keep its address.
|
|
21
|
+
blankReplacement: (_content, node) => {
|
|
22
|
+
const el = node;
|
|
23
|
+
if (EMBEDDED_TAGS.includes(el.nodeName))
|
|
24
|
+
return embeddedLink(el);
|
|
25
|
+
return node.isBlock ? "\n\n" : "";
|
|
26
|
+
},
|
|
18
27
|
});
|
|
19
28
|
// Turndown has no rule for <script>, <style> or <noscript>, so it falls
|
|
20
29
|
// back to emitting their text: a D2L description carrying an inline
|
|
@@ -81,6 +90,14 @@ function getTurndownService() {
|
|
|
81
90
|
return `[${content}](${cleaned}${titlePart})`;
|
|
82
91
|
},
|
|
83
92
|
});
|
|
93
|
+
// Embedded frames and media carry no text, so Turndown dropped them
|
|
94
|
+
// whole: a post whose details sat in an embedded PDF or video read as if
|
|
95
|
+
// that part did not exist. Keep the address as a link so the model can
|
|
96
|
+
// see that something was embedded and where it lives.
|
|
97
|
+
turndownService.addRule("embeddedContent", {
|
|
98
|
+
filter: ["iframe", "object", "embed", "video", "audio"],
|
|
99
|
+
replacement: (_content, node) => embeddedLink(node),
|
|
100
|
+
});
|
|
84
101
|
turndownService.addRule("imageSessionTokens", {
|
|
85
102
|
filter: "img",
|
|
86
103
|
replacement: (_content, node) => {
|
|
@@ -95,6 +112,16 @@ function getTurndownService() {
|
|
|
95
112
|
}
|
|
96
113
|
return turndownService;
|
|
97
114
|
}
|
|
115
|
+
const EMBEDDED_TAGS = ["IFRAME", "OBJECT", "EMBED", "VIDEO", "AUDIO"];
|
|
116
|
+
/** A link to an embedded frame or media file; empty when it has no usable address. */
|
|
117
|
+
function embeddedLink(el) {
|
|
118
|
+
const raw = el.getAttribute("src") ?? el.getAttribute("data") ?? "";
|
|
119
|
+
if (!raw.trim() || isUnsafeHref(raw))
|
|
120
|
+
return "";
|
|
121
|
+
const label = (el.getAttribute("title") || el.getAttribute("aria-label") || "Embedded content")
|
|
122
|
+
.replace(/[\[\]]/g, "");
|
|
123
|
+
return `[${label}](${stripD2lSessionParams(raw).replace(/([()])/g, "\\$1")})`;
|
|
124
|
+
}
|
|
98
125
|
/** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
|
|
99
126
|
function isUnsafeHref(href) {
|
|
100
127
|
return /^\s*(javascript|data):/i.test(href);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.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",
|