brightspace-mcp-server 5.1.17 → 5.1.19
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/token-manager.js +89 -0
- package/build/auth/xsrf-fetch.js +76 -0
- package/build/server.js +2 -1
- package/build/tools/get-discussions.js +64 -21
- package/build/utils/deep-links.js +7 -18
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { SessionStore } from "./session-store.js";
|
|
7
7
|
import { mintAccessToken } from "./token-mint.js";
|
|
8
|
+
import { fetchXsrfToken } from "./xsrf-fetch.js";
|
|
8
9
|
import { log } from "../utils/logger.js";
|
|
9
10
|
import { TokenRefreshError } from "../api/errors.js";
|
|
10
11
|
import { devActivity } from "../utils/dev-activity.js";
|
|
@@ -15,6 +16,8 @@ import { devActivity } from "../utils/dev-activity.js";
|
|
|
15
16
|
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
16
17
|
/** Matches the tokenTtl default in the app config. */
|
|
17
18
|
const DEFAULT_TOKEN_TTL_SECONDS = 3600;
|
|
19
|
+
/** How long a D2L_SESSION_COOKIE mint that failed in transit waits before trying again. */
|
|
20
|
+
const ENV_MINT_RETRY_MS = 60 * 1000;
|
|
18
21
|
/**
|
|
19
22
|
* TokenManager manages token lifecycle with in-memory caching and disk persistence.
|
|
20
23
|
* Handles expiry detection with a configurable refresh buffer.
|
|
@@ -25,8 +28,18 @@ export class TokenManager {
|
|
|
25
28
|
baseUrl;
|
|
26
29
|
tokenTtl;
|
|
27
30
|
mint;
|
|
31
|
+
fetchXsrf;
|
|
28
32
|
envAccessToken;
|
|
29
33
|
envSessionCookie;
|
|
34
|
+
mintEnvCookie;
|
|
35
|
+
/** The Bearer JWT minted from envSessionCookie, memory only. */
|
|
36
|
+
envMinted = null;
|
|
37
|
+
/** The XSRF token that last minted successfully, reused for the hourly re-mint. */
|
|
38
|
+
envCsrfToken;
|
|
39
|
+
/** Set once the env cookie is known not to mint, so later calls skip straight to the passthrough. */
|
|
40
|
+
envMintDisabled = false;
|
|
41
|
+
envMintRetryAt = 0;
|
|
42
|
+
envMintInFlight = null;
|
|
30
43
|
/** Single in-flight mint, so concurrent callers share one request. */
|
|
31
44
|
mintInFlight = null;
|
|
32
45
|
rejectedAccessToken = null;
|
|
@@ -53,8 +66,10 @@ export class TokenManager {
|
|
|
53
66
|
this.baseUrl = options.baseUrl ? new URL(options.baseUrl).origin : undefined;
|
|
54
67
|
this.tokenTtl = options.tokenTtl ?? DEFAULT_TOKEN_TTL_SECONDS;
|
|
55
68
|
this.mint = options.mint ?? mintAccessToken;
|
|
69
|
+
this.fetchXsrf = options.fetchXsrf ?? fetchXsrfToken;
|
|
56
70
|
this.envAccessToken = options.envAccessToken;
|
|
57
71
|
this.envSessionCookie = options.envSessionCookie;
|
|
72
|
+
this.mintEnvCookie = options.mintEnvCookie ?? false;
|
|
58
73
|
}
|
|
59
74
|
/**
|
|
60
75
|
* Get the current token if valid, otherwise null.
|
|
@@ -72,6 +87,11 @@ export class TokenManager {
|
|
|
72
87
|
// value again. (See D2LApiClient.tryAutoReauth / authExpiredMessage for
|
|
73
88
|
// the user-facing "paste a fresh one" error this produces.)
|
|
74
89
|
if (this.envAccessToken || this.envSessionCookie) {
|
|
90
|
+
if (!this.envAccessToken && this.mintEnvCookie && this.baseUrl) {
|
|
91
|
+
const minted = await this.envCookieBearer(rejectedAccessToken);
|
|
92
|
+
if (minted)
|
|
93
|
+
return minted;
|
|
94
|
+
}
|
|
75
95
|
const token = this.buildEnvToken();
|
|
76
96
|
if (rejectedAccessToken && token.accessToken === rejectedAccessToken) {
|
|
77
97
|
log("DEBUG", "The env-provided credential was rejected; no replacement is available");
|
|
@@ -255,6 +275,75 @@ export class TokenManager {
|
|
|
255
275
|
cookieHeader: this.envSessionCookie,
|
|
256
276
|
};
|
|
257
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The Bearer JWT minted from envSessionCookie, minting one when there is
|
|
280
|
+
* none or it expired or was just rejected. Null means use the cookie
|
|
281
|
+
* passthrough instead, which also keeps the existing "paste a fresh one"
|
|
282
|
+
* error for a dead cookie.
|
|
283
|
+
*/
|
|
284
|
+
async envCookieBearer(rejectedAccessToken) {
|
|
285
|
+
if (this.envMinted && rejectedAccessToken === this.envMinted.accessToken) {
|
|
286
|
+
log("DEBUG", "The JWT minted from D2L_SESSION_COOKIE was rejected; minting again");
|
|
287
|
+
this.envMinted = null;
|
|
288
|
+
}
|
|
289
|
+
if (this.envMinted && this.isValid(this.envMinted))
|
|
290
|
+
return this.envMinted;
|
|
291
|
+
if (this.envMintDisabled || Date.now() < this.envMintRetryAt)
|
|
292
|
+
return null;
|
|
293
|
+
this.envMintInFlight ??= this.runEnvMint().finally(() => {
|
|
294
|
+
this.envMintInFlight = null;
|
|
295
|
+
});
|
|
296
|
+
return this.envMintInFlight;
|
|
297
|
+
}
|
|
298
|
+
async runEnvMint() {
|
|
299
|
+
const baseUrl = this.baseUrl;
|
|
300
|
+
const cookieHeader = this.envSessionCookie;
|
|
301
|
+
let csrfToken = this.envCsrfToken;
|
|
302
|
+
if (!csrfToken) {
|
|
303
|
+
const xsrf = await this.fetchXsrf({ baseUrl, cookieHeader });
|
|
304
|
+
if (!xsrf.ok)
|
|
305
|
+
return this.envMintFailed(xsrf.reason, xsrf.detail);
|
|
306
|
+
csrfToken = xsrf.csrfToken;
|
|
307
|
+
}
|
|
308
|
+
let result;
|
|
309
|
+
try {
|
|
310
|
+
result = await this.mint({ baseUrl, cookieHeader, csrfToken });
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
return this.envMintFailed("transport", error instanceof Error ? error.message : String(error));
|
|
314
|
+
}
|
|
315
|
+
if (!result.ok) {
|
|
316
|
+
// A stale XSRF token is one cause of a refused mint; read a fresh one next time.
|
|
317
|
+
this.envCsrfToken = undefined;
|
|
318
|
+
return this.envMintFailed(result.reason, result.detail);
|
|
319
|
+
}
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
this.envCsrfToken = csrfToken;
|
|
322
|
+
this.envMinted = {
|
|
323
|
+
accessToken: result.accessToken,
|
|
324
|
+
tenantOrigin: this.baseUrl,
|
|
325
|
+
capturedAt: now,
|
|
326
|
+
expiresAt: now + this.tokenTtl * 1000,
|
|
327
|
+
source: "env",
|
|
328
|
+
cookieHeader,
|
|
329
|
+
};
|
|
330
|
+
log("INFO", "Minted a Bearer token from D2L_SESSION_COOKIE without a browser");
|
|
331
|
+
return this.envMinted;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* A dead cookie or a page with no XSRF token will not mint later either, so
|
|
335
|
+
* stop trying; a transport failure is retried after ENV_MINT_RETRY_MS.
|
|
336
|
+
*/
|
|
337
|
+
envMintFailed(reason, detail) {
|
|
338
|
+
if (reason === "transport") {
|
|
339
|
+
this.envMintRetryAt = Date.now() + ENV_MINT_RETRY_MS;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
this.envMintDisabled = true;
|
|
343
|
+
}
|
|
344
|
+
log("DEBUG", `Could not mint from D2L_SESSION_COOKIE (${reason}${detail ? `: ${detail}` : ""}); using the cookie as-is`);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
258
347
|
/**
|
|
259
348
|
* Only use persisted session material, including updates from other processes.
|
|
260
349
|
*/
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT: see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { log } from "../utils/logger.js";
|
|
7
|
+
/** Same marker token-mint.ts keys on: a dead session gets a 200 redirect stub. */
|
|
8
|
+
const EXPIRED_MARKER = "/d2l/login?sessionExpired=1";
|
|
9
|
+
/** Same identity the API client sends, see buildAuthHeaders in api/client.ts. */
|
|
10
|
+
const USER_AGENT = "BrightspaceMCP/1.0 (Rohan Muppa; github.com/rohanmuppa/brightspace-mcp-server)";
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
12
|
+
/**
|
|
13
|
+
* Where a Brightspace page carries its XSRF token, most specific first.
|
|
14
|
+
*
|
|
15
|
+
* The browser path reads D2L.LP.Web.Authentication.Xsrf.GetXsrfToken(), whose
|
|
16
|
+
* value is the one the page also writes to localStorage as "XSRF.Token"
|
|
17
|
+
* (the same 32 characters). Measured against purdue.brightspace.com: the
|
|
18
|
+
* cookie-only HTML carries it in an inline
|
|
19
|
+
* `localStorage.setItem('XSRF.Token','...')`, so that is the first place to
|
|
20
|
+
* look; the meta tag is the browser path's own fallback.
|
|
21
|
+
*/
|
|
22
|
+
const XSRF_PATTERNS = [
|
|
23
|
+
/XSRF\.Token\\?["']\s*,\s*\\?["']([A-Za-z0-9_-]{16,})\\?["']/,
|
|
24
|
+
/<meta[^>]*name=["']d2l-xsrf-token["'][^>]*content=["']([A-Za-z0-9_-]{16,})["']/i,
|
|
25
|
+
/<meta[^>]*content=["']([A-Za-z0-9_-]{16,})["'][^>]*name=["']d2l-xsrf-token["']/i,
|
|
26
|
+
];
|
|
27
|
+
/** Pull the XSRF token out of a Brightspace page's HTML, or null when absent. */
|
|
28
|
+
export function parseXsrfToken(html) {
|
|
29
|
+
for (const pattern of XSRF_PATTERNS) {
|
|
30
|
+
const match = pattern.exec(html);
|
|
31
|
+
if (match)
|
|
32
|
+
return match[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the XSRF token the token mint needs from /d2l/home over plain HTTP,
|
|
38
|
+
* using only the session cookies. This is what lets a pasted
|
|
39
|
+
* D2L_SESSION_COOKIE be traded for a Bearer JWT without a browser.
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchXsrfToken({ baseUrl, cookieHeader, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, }) {
|
|
42
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/d2l/home`;
|
|
43
|
+
let status;
|
|
44
|
+
let body;
|
|
45
|
+
let location;
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetchImpl(url, {
|
|
48
|
+
method: "GET",
|
|
49
|
+
headers: { cookie: cookieHeader, "User-Agent": USER_AGENT, accept: "text/html" },
|
|
50
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
51
|
+
redirect: "manual",
|
|
52
|
+
});
|
|
53
|
+
status = response.status;
|
|
54
|
+
body = await response.text();
|
|
55
|
+
location = response.headers?.get("location") ?? null;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return { ok: false, reason: "transport", detail: error instanceof Error ? error.message : String(error) };
|
|
59
|
+
}
|
|
60
|
+
if (status >= 500 || status === 429)
|
|
61
|
+
return { ok: false, reason: "transport", detail: `HTTP ${status}` };
|
|
62
|
+
// A dead session either redirects to the login page or answers 200 with the
|
|
63
|
+
// redirect stub. Either way the cookie is no good for minting.
|
|
64
|
+
if (status === 401 || body.includes(EXPIRED_MARKER) || (status >= 300 && status < 400 && location?.includes("/d2l/login"))) {
|
|
65
|
+
log("DEBUG", "The home page answered as a signed-out session");
|
|
66
|
+
return { ok: false, reason: "sessionExpired" };
|
|
67
|
+
}
|
|
68
|
+
if (status < 200 || status >= 300)
|
|
69
|
+
return { ok: false, reason: "transport", detail: `HTTP ${status}` };
|
|
70
|
+
const csrfToken = parseXsrfToken(body);
|
|
71
|
+
if (!csrfToken) {
|
|
72
|
+
log("DEBUG", "No XSRF token found in the home page HTML");
|
|
73
|
+
return { ok: false, reason: "notFound" };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, csrfToken };
|
|
76
|
+
}
|
package/build/server.js
CHANGED
|
@@ -30,6 +30,7 @@ export function createRuntime(config, version) {
|
|
|
30
30
|
tokenTtl: config.tokenTtl,
|
|
31
31
|
envAccessToken: config.envAccessToken,
|
|
32
32
|
envSessionCookie: config.envSessionCookie,
|
|
33
|
+
mintEnvCookie: true,
|
|
33
34
|
});
|
|
34
35
|
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
|
|
35
36
|
// escape hatches (Docker, headless Linux, WSL, hardware-key MFA
|
|
@@ -91,7 +92,7 @@ export function createMcpServer(runtime) {
|
|
|
91
92
|
registerGetRoster(server, apiClient);
|
|
92
93
|
registerGetMyGroups(server, apiClient);
|
|
93
94
|
registerGetSyllabus(server, apiClient);
|
|
94
|
-
registerGetDiscussions(server, apiClient);
|
|
95
|
+
registerGetDiscussions(server, apiClient, config.baseUrl);
|
|
95
96
|
registerGetVideoTranscript(server, apiClient);
|
|
96
97
|
registerGetServerInfo(server, config, version, apiClient);
|
|
97
98
|
registerSearchCourse(server, apiClient);
|
|
@@ -8,6 +8,7 @@ import { GetDiscussionsSchema } 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 { discussionUrl } from "../utils/deep-links.js";
|
|
11
12
|
/**
|
|
12
13
|
* Fetch a course's discussion forums. Shared with search_course so both
|
|
13
14
|
* tools hit the same endpoint (and cache entry) instead of duplicating it.
|
|
@@ -31,7 +32,7 @@ export async function fetchForumTopics(apiClient, courseId, forumId) {
|
|
|
31
32
|
/**
|
|
32
33
|
* Register get_discussions tool
|
|
33
34
|
*/
|
|
34
|
-
export function registerGetDiscussions(server, apiClient) {
|
|
35
|
+
export function registerGetDiscussions(server, apiClient, baseUrl) {
|
|
35
36
|
server.registerTool("get_discussions", {
|
|
36
37
|
title: "Get Discussions",
|
|
37
38
|
annotations: { readOnlyHint: true },
|
|
@@ -47,7 +48,7 @@ export function registerGetDiscussions(server, apiClient) {
|
|
|
47
48
|
}
|
|
48
49
|
// Specific topic — get posts
|
|
49
50
|
if (forumId !== undefined && topicId !== undefined) {
|
|
50
|
-
return await getTopicPosts(apiClient, courseId, forumId, topicId);
|
|
51
|
+
return await getTopicPosts(apiClient, courseId, forumId, topicId, baseUrl);
|
|
51
52
|
}
|
|
52
53
|
// Specific forum — get its topics + posts
|
|
53
54
|
if (forumId !== undefined) {
|
|
@@ -169,40 +170,82 @@ async function getForumDetail(apiClient, courseId, forumId) {
|
|
|
169
170
|
});
|
|
170
171
|
}
|
|
171
172
|
/**
|
|
172
|
-
*
|
|
173
|
+
* Fetch one topic's metadata. The forum's topic list comes first: it is the
|
|
174
|
+
* route get_discussions already lists topics from (and caches), and some
|
|
175
|
+
* tenants refuse a student the per-topic route for a topic that list shows
|
|
176
|
+
* (#364). The per-topic route is the fallback for a topic the list omits or
|
|
177
|
+
* a list the tenant refuses.
|
|
173
178
|
*/
|
|
174
|
-
async function
|
|
175
|
-
|
|
179
|
+
async function fetchTopic(apiClient, courseId, forumId, topicId) {
|
|
180
|
+
try {
|
|
181
|
+
const listed = (await fetchForumTopics(apiClient, courseId, forumId)).find((t) => t.TopicId === topicId);
|
|
182
|
+
if (listed)
|
|
183
|
+
return listed;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
log("DEBUG", `Topic list for forum ${forumId} unavailable, trying topic ${topicId} directly`, error);
|
|
187
|
+
}
|
|
176
188
|
const topicPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}`);
|
|
177
|
-
|
|
189
|
+
return apiClient.get(topicPath, {
|
|
178
190
|
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
179
191
|
});
|
|
180
|
-
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get all posts for a specific topic. A posts read the tenant refuses (403)
|
|
195
|
+
* still returns the topic, with posts null and postsUnavailable naming the
|
|
196
|
+
* failed step, so a permission restriction never reads as "no posts".
|
|
197
|
+
*/
|
|
198
|
+
async function getTopicPosts(apiClient, courseId, forumId, topicId, baseUrl) {
|
|
199
|
+
const topic = await fetchTopic(apiClient, courseId, forumId, topicId);
|
|
181
200
|
const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}/posts/`);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
201
|
+
let posts;
|
|
202
|
+
try {
|
|
203
|
+
posts = await apiClient.get(postsPath, {
|
|
204
|
+
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error?.status !== 403)
|
|
209
|
+
throw error;
|
|
210
|
+
log("INFO", `get_discussions: Posts for topic ${topicId} in forum ${forumId} refused (403)`);
|
|
211
|
+
return toolResponse({
|
|
212
|
+
courseId,
|
|
213
|
+
forumId,
|
|
214
|
+
topic: formatTopic(topic),
|
|
215
|
+
postCount: null,
|
|
216
|
+
posts: null,
|
|
217
|
+
postsUnavailable: {
|
|
218
|
+
operation: "listPosts",
|
|
219
|
+
status: 403,
|
|
220
|
+
reason: "Brightspace refused this account's request for the topic's posts. The posts may exist; open the topic in a browser to see what this account can read.",
|
|
221
|
+
url: baseUrl ? discussionUrl(baseUrl, courseId, topicId) : null,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
185
225
|
// Count what is returned: formatPosts drops deleted posts.
|
|
186
226
|
const formatted = formatPosts(posts);
|
|
187
227
|
log("INFO", `get_discussions: Retrieved ${formatted.length} posts for topic ${topicId} in forum ${forumId}`);
|
|
188
228
|
return toolResponse({
|
|
189
229
|
courseId,
|
|
190
230
|
forumId,
|
|
191
|
-
topic:
|
|
192
|
-
topicId: topic.TopicId,
|
|
193
|
-
name: topic.Name,
|
|
194
|
-
description: topic.Description?.Html
|
|
195
|
-
? convertHtmlToMarkdown(topic.Description.Html).markdown
|
|
196
|
-
: topic.Description?.Text ?? null,
|
|
197
|
-
dueDate: topic.DueDate,
|
|
198
|
-
isLocked: topic.IsLocked,
|
|
199
|
-
mustPostToParticipate: topic.MustPostToParticipate,
|
|
200
|
-
scoreOutOf: topic.ScoreOutOf,
|
|
201
|
-
},
|
|
231
|
+
topic: formatTopic(topic),
|
|
202
232
|
postCount: formatted.length,
|
|
203
233
|
posts: formatted,
|
|
204
234
|
});
|
|
205
235
|
}
|
|
236
|
+
function formatTopic(topic) {
|
|
237
|
+
return {
|
|
238
|
+
topicId: topic.TopicId,
|
|
239
|
+
name: topic.Name,
|
|
240
|
+
description: topic.Description?.Html
|
|
241
|
+
? convertHtmlToMarkdown(topic.Description.Html).markdown
|
|
242
|
+
: topic.Description?.Text ?? null,
|
|
243
|
+
dueDate: topic.DueDate,
|
|
244
|
+
isLocked: topic.IsLocked,
|
|
245
|
+
mustPostToParticipate: topic.MustPostToParticipate,
|
|
246
|
+
scoreOutOf: topic.ScoreOutOf,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
206
249
|
/**
|
|
207
250
|
* Format posts into a clean thread structure.
|
|
208
251
|
*/
|
|
@@ -6,16 +6,11 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Deep links into the Brightspace web UI.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* without live markup to check it against, and no one has confirmed it against
|
|
15
|
-
* a real tenant since. Treat it as unverified: if a student reports that a
|
|
16
|
-
* discussion link lands on the wrong page or a "not found", harvest the real
|
|
17
|
-
* URL from a course's own discussion list and fix the template here rather
|
|
18
|
-
* than assuming this one is right.
|
|
9
|
+
* Every template here was harvested from live Brightspace markup: they are
|
|
10
|
+
* the same URLs the course pages themselves link to, so they open the item
|
|
11
|
+
* directly. The discussion template was first written from the shape of the
|
|
12
|
+
* others and answered 404 on a real tenant; it now uses the link the course's
|
|
13
|
+
* own Discussions List page gives each topic.
|
|
19
14
|
*/
|
|
20
15
|
/** Drop trailing slashes so the templates below join cleanly. */
|
|
21
16
|
function trimBaseUrl(baseUrl) {
|
|
@@ -40,15 +35,9 @@ export function contentTopicUrl(baseUrl, courseId, topicId) {
|
|
|
40
35
|
export function gradebookUrl(baseUrl, courseId) {
|
|
41
36
|
return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
|
|
42
37
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Link to the thread list of a discussion topic.
|
|
45
|
-
*
|
|
46
|
-
* Unverified against a live tenant -- see the note at the top of this file.
|
|
47
|
-
* The topic's forum id is available at every call site, so if the real
|
|
48
|
-
* template turns out to need one, it can be threaded through.
|
|
49
|
-
*/
|
|
38
|
+
/** Link to a discussion topic's page, which lists its threads. */
|
|
50
39
|
export function discussionUrl(baseUrl, courseId, topicId) {
|
|
51
|
-
return `${trimBaseUrl(baseUrl)}/d2l/
|
|
40
|
+
return `${trimBaseUrl(baseUrl)}/d2l/le/${courseId}/discussions/topics/${topicId}/View`;
|
|
52
41
|
}
|
|
53
42
|
/**
|
|
54
43
|
* Link to a course's calendar. Calendar events have no per-event student page
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.19",
|
|
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",
|