canvas-lms-mcp 2.0.0 → 2.2.0
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 +5 -5
- package/dist/canvas/activity-stream.d.ts +35 -0
- package/dist/canvas/activity-stream.d.ts.map +1 -0
- package/dist/canvas/client.d.ts +3 -1
- package/dist/canvas/client.d.ts.map +1 -1
- package/dist/canvas/index.cjs +3 -3
- package/dist/canvas/index.d.ts +4 -0
- package/dist/canvas/index.d.ts.map +1 -1
- package/dist/canvas/index.js +2 -2
- package/dist/canvas/planner.d.ts +31 -0
- package/dist/canvas/planner.d.ts.map +1 -0
- package/dist/canvas/types.d.ts +326 -1
- package/dist/canvas/types.d.ts.map +1 -1
- package/dist/{chunk-KNYGBEL5.cjs → chunk-C2C3TYWD.cjs} +16 -8
- package/dist/chunk-C2C3TYWD.cjs.map +1 -0
- package/dist/{chunk-66PSR2DZ.cjs → chunk-EMIO4XD4.cjs} +75 -4
- package/dist/chunk-EMIO4XD4.cjs.map +1 -0
- package/dist/{chunk-KHJEIVEQ.js → chunk-FKTJGYLG.js} +410 -9
- package/dist/chunk-FKTJGYLG.js.map +1 -0
- package/dist/{chunk-5B3JDDYC.js → chunk-FP4A34TD.js} +73 -2
- package/dist/chunk-FP4A34TD.js.map +1 -0
- package/dist/{chunk-AWI5UBRV.cjs → chunk-JR74DYXY.cjs} +805 -404
- package/dist/chunk-JR74DYXY.cjs.map +1 -0
- package/dist/{chunk-VVIZW7HE.js → chunk-W5DF5YBG.js} +13 -5
- package/dist/{chunk-VVIZW7HE.js.map → chunk-W5DF5YBG.js.map} +1 -1
- package/dist/http.cjs +6 -6
- package/dist/http.js +3 -3
- package/dist/init.cjs +3 -3
- package/dist/init.js +1 -1
- package/dist/provenance/fields.d.ts.map +1 -1
- package/dist/pseudonym/coverage.d.ts.map +1 -1
- package/dist/pseudonym/pseudonymizer.d.ts +118 -1
- package/dist/pseudonym/pseudonymizer.d.ts.map +1 -1
- package/dist/server.cjs +4 -4
- package/dist/server.js +3 -3
- package/dist/stdio.cjs +4 -4
- package/dist/stdio.js +3 -3
- package/dist/tools/student.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-5B3JDDYC.js.map +0 -1
- package/dist/chunk-66PSR2DZ.cjs.map +0 -1
- package/dist/chunk-AWI5UBRV.cjs.map +0 -1
- package/dist/chunk-KHJEIVEQ.js.map +0 -1
- package/dist/chunk-KNYGBEL5.cjs.map +0 -1
|
@@ -25,11 +25,11 @@ import {
|
|
|
25
25
|
compareCanvasIds,
|
|
26
26
|
mapWithConcurrency,
|
|
27
27
|
normalizeCanvasIdInput
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-FP4A34TD.js";
|
|
29
29
|
import {
|
|
30
30
|
CanvasApiError,
|
|
31
31
|
version
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-W5DF5YBG.js";
|
|
33
33
|
|
|
34
34
|
// src/server.ts
|
|
35
35
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -148,6 +148,8 @@ function isNoEntError(err) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// src/pseudonym/pseudonymizer.ts
|
|
151
|
+
var STREAM_NO_CONTEXT_SCOPE = "_stream_nocontext";
|
|
152
|
+
var WITHHELD_AUTHOR_NAME = "Author name withheld";
|
|
151
153
|
var Pseudonymizer = class {
|
|
152
154
|
host;
|
|
153
155
|
rootDir;
|
|
@@ -304,6 +306,83 @@ var Pseudonymizer = class {
|
|
|
304
306
|
);
|
|
305
307
|
return { ...group, appointments };
|
|
306
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Pseudonymize the PII arms of a `GET /users/self/activity_stream` response.
|
|
311
|
+
*
|
|
312
|
+
* **Scope.** The stream is cross-course by construction, so there is no single
|
|
313
|
+
* course to key the pseudonym map on. Each item is scoped individually by its
|
|
314
|
+
* own `course_id`, which is what every other wrapped tool already does — so
|
|
315
|
+
* the stream agrees with `list_submissions` on the same student in the same
|
|
316
|
+
* course. A group-context item carries no `course_id` at all (Canvas emits one
|
|
317
|
+
* key, named after the context type), and gets the isolated synthetic scope
|
|
318
|
+
* `_group_<group_id>`, following the `_apptgrp_<id>` precedent above. Course
|
|
319
|
+
* and group identity maps are never merged.
|
|
320
|
+
*
|
|
321
|
+
* **Three arms carry a name; the fourth does not.** Measured against
|
|
322
|
+
* `lib/api/v1/stream_item.rb#stream_item_json`:
|
|
323
|
+
*
|
|
324
|
+
* - `root_discussion_entries[].user.user_name` — a name WITH an id, so it gets
|
|
325
|
+
* an ordinary scope-stable pseudonym.
|
|
326
|
+
* - the `Submission` arm's `user` + `submission_comments[].author_name` —
|
|
327
|
+
* delegated to `anonymizeSubmission`, after pre-warming the non-grader
|
|
328
|
+
* comment authors so a peer reviewer's name cannot survive on a
|
|
329
|
+
* single-submission payload (see the inline note below).
|
|
330
|
+
* - `author_name` on a `DiscussionEntry` item — a name with NO identifier
|
|
331
|
+
* anywhere in the item, so no stable pseudonym is derivable. Withheld
|
|
332
|
+
* outright rather than left in place: failing closed is the only safe
|
|
333
|
+
* reading, and `html_url` still reaches the pseudonymized entry through
|
|
334
|
+
* `get_discussion`.
|
|
335
|
+
* - the `Conversation` arm's `latest_messages[]` is `{id, created_at,
|
|
336
|
+
* author_id, message, participating_user_ids}` — identifiers and text, no
|
|
337
|
+
* name. Nothing to rewrite; ids are preserved by every existing
|
|
338
|
+
* `anonymize*` method too (`applyPseudonymToUser` keeps `user.id`), and the
|
|
339
|
+
* message text is provenance-fenced rather than pseudonymized.
|
|
340
|
+
*/
|
|
341
|
+
async anonymizeActivityStream(items) {
|
|
342
|
+
if (!this.isEnabled() || !this.host) return [...items];
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const item of items) {
|
|
345
|
+
out.push(await this.anonymizeActivityStreamEntry(item));
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Pseudonymize the PII arm of a `GET /planner/items` response (BRU-2878).
|
|
351
|
+
*
|
|
352
|
+
* `submissions.feedback` (from `submission_statuses_for`) carries an
|
|
353
|
+
* `author_name` with NO identifier anywhere on the item — the plannable set
|
|
354
|
+
* includes `assessment_request` / `peer_review_sub_assignment`, where this
|
|
355
|
+
* feedback can originate from a peer reviewer rather than the grader, and
|
|
356
|
+
* there is no `author_id` sibling to classify against like
|
|
357
|
+
* `anonymizeSubmission` does. This is the exact shape already handled for
|
|
358
|
+
* `author_name` on a `DiscussionEntry` activity-stream item above: nothing
|
|
359
|
+
* to key a stable pseudonym on, so it is withheld outright rather than left
|
|
360
|
+
* in place — failing closed is the only safe reading. `author_avatar_url` is
|
|
361
|
+
* cleared alongside it, matching `applyPseudonymToUser`'s treatment of
|
|
362
|
+
* `avatar_url`: a masked name next to the real author's photo would defeat
|
|
363
|
+
* the point. No other arm of a planner item carries a third-party name.
|
|
364
|
+
*/
|
|
365
|
+
async anonymizePlannerItems(items) {
|
|
366
|
+
if (!this.isEnabled()) return [...items];
|
|
367
|
+
return items.map((item) => {
|
|
368
|
+
if (item.submissions === false) return item;
|
|
369
|
+
const feedback = item.submissions.feedback;
|
|
370
|
+
if (typeof feedback?.author_name !== "string" || feedback.author_name.length === 0) {
|
|
371
|
+
return item;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
...item,
|
|
375
|
+
submissions: {
|
|
376
|
+
...item.submissions,
|
|
377
|
+
feedback: {
|
|
378
|
+
...feedback,
|
|
379
|
+
author_name: WITHHELD_AUTHOR_NAME,
|
|
380
|
+
author_avatar_url: feedback.author_avatar_url == null ? feedback.author_avatar_url : null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
307
386
|
/**
|
|
308
387
|
* Look up the real user_id behind a pseudonym. Returns `null` when reverse
|
|
309
388
|
* lookup is disabled, the host is invalid, or the pseudonym is unknown.
|
|
@@ -335,6 +414,77 @@ var Pseudonymizer = class {
|
|
|
335
414
|
return null;
|
|
336
415
|
}
|
|
337
416
|
// --- Internals --------------------------------------------------------------
|
|
417
|
+
/**
|
|
418
|
+
* The pseudonym scope for one activity-stream item. Exactly one of
|
|
419
|
+
* `course_id` / `group_id` is present on a real item; `STREAM_NO_CONTEXT_SCOPE`
|
|
420
|
+
* is the fail-closed backstop for neither, so an unexpected shape still gets
|
|
421
|
+
* a pseudonym rather than passing a real name through.
|
|
422
|
+
*/
|
|
423
|
+
activityStreamScope(item) {
|
|
424
|
+
if (item.course_id !== void 0) return item.course_id;
|
|
425
|
+
if (item.group_id !== void 0) return `_group_${item.group_id}`;
|
|
426
|
+
return STREAM_NO_CONTEXT_SCOPE;
|
|
427
|
+
}
|
|
428
|
+
async anonymizeActivityStreamEntry(item) {
|
|
429
|
+
const scope = this.activityStreamScope(item);
|
|
430
|
+
let out = item;
|
|
431
|
+
if (item.root_discussion_entries && item.root_discussion_entries.length > 0) {
|
|
432
|
+
const entries = [];
|
|
433
|
+
for (const entry of item.root_discussion_entries) {
|
|
434
|
+
if (!entry.user) {
|
|
435
|
+
entries.push(entry);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const rootUserId = entry.user.user_id;
|
|
439
|
+
const resolvedName = rootUserId === void 0 || rootUserId === null ? WITHHELD_AUTHOR_NAME : (await this.anonymizeUser(scope, {
|
|
440
|
+
id: rootUserId,
|
|
441
|
+
name: entry.user.user_name
|
|
442
|
+
})).name;
|
|
443
|
+
entries.push({ ...entry, user: { ...entry.user, user_name: resolvedName } });
|
|
444
|
+
}
|
|
445
|
+
out = { ...out, root_discussion_entries: entries };
|
|
446
|
+
}
|
|
447
|
+
if (typeof item.author_name === "string" && item.author_name.length > 0) {
|
|
448
|
+
out = { ...out, author_name: WITHHELD_AUTHOR_NAME };
|
|
449
|
+
}
|
|
450
|
+
if (item.user !== void 0 || item.submission_comments !== void 0) {
|
|
451
|
+
if (item.user !== void 0) await this.anonymizeUser(scope, item.user);
|
|
452
|
+
const graderKey = item.grader_id == null ? null : String(item.grader_id);
|
|
453
|
+
for (const comment of item.submission_comments ?? []) {
|
|
454
|
+
const authorKey = submissionCommentAuthorKey(comment);
|
|
455
|
+
if (authorKey === null) continue;
|
|
456
|
+
if (graderKey !== null && authorKey === graderKey) continue;
|
|
457
|
+
await this.anonymizeUser(scope, {
|
|
458
|
+
id: comment.author_id,
|
|
459
|
+
name: comment.author_name
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const anonymized = await this.anonymizeSubmission(scope, item);
|
|
463
|
+
out = { ...out };
|
|
464
|
+
if (anonymized.user !== void 0) out.user = anonymized.user;
|
|
465
|
+
if (anonymized.submission_comments !== void 0) {
|
|
466
|
+
out.submission_comments = anonymized.submission_comments;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (item.discussion_entries && item.discussion_entries.length > 0) {
|
|
470
|
+
out = {
|
|
471
|
+
...out,
|
|
472
|
+
discussion_entries: await this.anonymizeSubmissionDiscussionEntries(
|
|
473
|
+
scope,
|
|
474
|
+
item.discussion_entries
|
|
475
|
+
)
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (typeof item.proxy_submitter === "string" && item.proxy_submitter.length > 0) {
|
|
479
|
+
const id = item.proxy_submitter_id;
|
|
480
|
+
const resolved = id === void 0 || id === null ? WITHHELD_AUTHOR_NAME : (await this.anonymizeUser(scope, {
|
|
481
|
+
id,
|
|
482
|
+
name: item.proxy_submitter
|
|
483
|
+
})).name;
|
|
484
|
+
out = { ...out, proxy_submitter: resolved };
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
338
488
|
async anonymizeCalendarEventUser(apptGroupId, event) {
|
|
339
489
|
if (!event.user) return event;
|
|
340
490
|
const anonymizedUser = await this.anonymizeUser(`_apptgrp_${apptGroupId}`, event.user);
|
|
@@ -397,12 +547,74 @@ var Pseudonymizer = class {
|
|
|
397
547
|
const map = this.host ? await this.loadCourseMap(this.host, courseId) ?? null : null;
|
|
398
548
|
const out = [];
|
|
399
549
|
for (const c of comments) {
|
|
400
|
-
const
|
|
550
|
+
const authorKey = submissionCommentAuthorKey(c);
|
|
551
|
+
const pseudonym = authorKey === null ? void 0 : map?.students[authorKey]?.pseudonym;
|
|
552
|
+
const next = { ...c };
|
|
401
553
|
if (pseudonym) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
554
|
+
next.author_name = pseudonym;
|
|
555
|
+
if (c.author) next.author = applyPseudonymToDisplay(c.author, pseudonym);
|
|
556
|
+
}
|
|
557
|
+
out.push(next);
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Pseudonymize a Canvas `user_display_json` object, which spells the name
|
|
563
|
+
* `display_name` rather than `name`.
|
|
564
|
+
*
|
|
565
|
+
* Role classification runs through {@link anonymizeUser}, so staff keep their
|
|
566
|
+
* name here exactly as they do everywhere else. A display object with a name
|
|
567
|
+
* but NO `id` has nothing to key a stable pseudonym on, so the name is
|
|
568
|
+
* withheld rather than passed through — the same fail-closed reading as the
|
|
569
|
+
* `DiscussionEntry` arm's bare `author_name`.
|
|
570
|
+
*/
|
|
571
|
+
async anonymizeDisplayUser(scope, display) {
|
|
572
|
+
const name = display.display_name;
|
|
573
|
+
if (typeof name !== "string" || name.length === 0) return display;
|
|
574
|
+
if (display.id === void 0 || display.id === null) {
|
|
575
|
+
return applyPseudonymToDisplay(display, WITHHELD_AUTHOR_NAME);
|
|
576
|
+
}
|
|
577
|
+
const resolved = await this.anonymizeUser(scope, {
|
|
578
|
+
id: display.id,
|
|
579
|
+
name
|
|
580
|
+
});
|
|
581
|
+
if (resolved.name === name) return display;
|
|
582
|
+
return applyPseudonymToDisplay(display, resolved.name);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Pseudonymize a `discussion_entries[]` array from the Submission arm,
|
|
586
|
+
* recursing through `recent_replies`.
|
|
587
|
+
*
|
|
588
|
+
* Three identity surfaces per entry and they do not co-occur — `user_name`
|
|
589
|
+
* and `user_id` are dropped on a deleted entry while the `user` display
|
|
590
|
+
* object survives it — so each is resolved independently. Both resolve
|
|
591
|
+
* through the same per-user map entry, so one person gets one label however
|
|
592
|
+
* many keys spell their name.
|
|
593
|
+
*/
|
|
594
|
+
async anonymizeSubmissionDiscussionEntries(scope, entries) {
|
|
595
|
+
const out = [];
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
let next = entry;
|
|
598
|
+
if (typeof entry.user_name === "string" && entry.user_name.length > 0) {
|
|
599
|
+
const resolved = entry.user_id === void 0 || entry.user_id === null ? WITHHELD_AUTHOR_NAME : (await this.anonymizeUser(scope, {
|
|
600
|
+
id: entry.user_id,
|
|
601
|
+
name: entry.user_name
|
|
602
|
+
})).name;
|
|
603
|
+
next = { ...next, user_name: resolved };
|
|
604
|
+
}
|
|
605
|
+
if (entry.user) {
|
|
606
|
+
next = { ...next, user: await this.anonymizeDisplayUser(scope, entry.user) };
|
|
607
|
+
}
|
|
608
|
+
if (entry.recent_replies && entry.recent_replies.length > 0) {
|
|
609
|
+
next = {
|
|
610
|
+
...next,
|
|
611
|
+
recent_replies: await this.anonymizeSubmissionDiscussionEntries(
|
|
612
|
+
scope,
|
|
613
|
+
entry.recent_replies
|
|
614
|
+
)
|
|
615
|
+
};
|
|
405
616
|
}
|
|
617
|
+
out.push(next);
|
|
406
618
|
}
|
|
407
619
|
return out;
|
|
408
620
|
}
|
|
@@ -463,9 +675,18 @@ var Pseudonymizer = class {
|
|
|
463
675
|
}
|
|
464
676
|
}
|
|
465
677
|
};
|
|
678
|
+
function submissionCommentAuthorKey(comment) {
|
|
679
|
+
return comment.author_id == null ? null : String(comment.author_id);
|
|
680
|
+
}
|
|
466
681
|
function classifyRoleFromEnrollment(enrollment) {
|
|
467
682
|
return classifyRole({}, [enrollment]);
|
|
468
683
|
}
|
|
684
|
+
function applyPseudonymToDisplay(display, pseudonym) {
|
|
685
|
+
const out = { ...display, display_name: pseudonym };
|
|
686
|
+
if (display.avatar_image_url !== void 0) out.avatar_image_url = null;
|
|
687
|
+
if (display.pronouns !== void 0) out.pronouns = null;
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
469
690
|
function applyPseudonymToUser(user, pseudonym) {
|
|
470
691
|
const out = {
|
|
471
692
|
...user,
|
|
@@ -540,6 +761,45 @@ var UNTRUSTED_FIELDS = {
|
|
|
540
761
|
// today and exists so a widened projection is covered the day it lands.
|
|
541
762
|
list_submissions_awaiting_grading: { body: "submission body", comment: "submission comment" },
|
|
542
763
|
get_my_submission_feedback: { comment: "submission comment" },
|
|
764
|
+
// The cross-course activity stream. `message` covers the DiscussionTopic /
|
|
765
|
+
// Announcement arm, each `root_discussion_entries[].message`, and each
|
|
766
|
+
// `latest_messages[].message` on a Conversation item; `body` and `comment`
|
|
767
|
+
// cover the Submission arm, whose merged `submission_json` carries the
|
|
768
|
+
// submission body and `submission_comments[]` (Canvas sets each comment's
|
|
769
|
+
// `body` from its `comment`, so both keys hold the same text).
|
|
770
|
+
//
|
|
771
|
+
// Three deliberate omissions. `title` is NOT fenced, following
|
|
772
|
+
// list_discussions / get_discussion — and here it would also hit the Message
|
|
773
|
+
// arm's Canvas-generated notification subject and the AssessmentRequest
|
|
774
|
+
// arm's synthesised "Peer Review for …" string, both server-authored.
|
|
775
|
+
// `name` is NOT fenced: the Submission arm merges `includes = %w[… assignment
|
|
776
|
+
// course …]`, so it would fence course and assignment names — the exact
|
|
777
|
+
// over-match this header warns about. `author_name` is NOT fenced because it
|
|
778
|
+
// is a name, and names are the pseudonymizer's job; the two layers must not
|
|
779
|
+
// both rewrite one value.
|
|
780
|
+
get_my_activity_stream: {
|
|
781
|
+
message: "activity stream message",
|
|
782
|
+
body: "submission body",
|
|
783
|
+
comment: "submission comment"
|
|
784
|
+
},
|
|
785
|
+
// The cross-course planner. Despite carrying a `discussion_topic` /
|
|
786
|
+
// `announcement` plannable type, NEITHER arm's actual message text reaches
|
|
787
|
+
// this response: `lib/api/v1/planner_item.rb#plannable_json` slices every
|
|
788
|
+
// plannable down to `API_PLANNABLE_FIELDS` (+ a small per-type extra-field
|
|
789
|
+
// list), and `message`/`body` are in neither — a discussion's real message
|
|
790
|
+
// is dropped entirely at the planner layer (read at pinned SHA
|
|
791
|
+
// 1c9f0bb8013ed69c4f2efe11fd483025469b7e6c, verified by re-deriving the
|
|
792
|
+
// slice from source rather than assuming the activity-stream registry
|
|
793
|
+
// entry carries over). The one free-text field that DOES survive the slice
|
|
794
|
+
// is a calendar event's `description` (`CALENDAR_PLANNABLE_FIELDS`), which
|
|
795
|
+
// can be course-wide and teacher-authored. `planner_note`'s own `details`
|
|
796
|
+
// field is excluded on purpose: it is the CALLER'S OWN note text, not
|
|
797
|
+
// third-party Canvas content, matching the authorship test this fencing
|
|
798
|
+
// design is built on. `title`, `location_name` and `location_address` are
|
|
799
|
+
// short metadata, not prose, and follow the existing title precedent.
|
|
800
|
+
list_my_planner_items: {
|
|
801
|
+
description: "calendar event description"
|
|
802
|
+
},
|
|
543
803
|
// Rank 2 — any enrolled student can author a discussion message, and
|
|
544
804
|
// `post_discussion_entry` / `update_discussion` publish as the operator.
|
|
545
805
|
get_discussion: { message: "discussion message" },
|
|
@@ -6056,6 +6316,17 @@ var MY_SUBMISSION_FEEDBACK_INCLUDE = [
|
|
|
6056
6316
|
"course",
|
|
6057
6317
|
"read_status"
|
|
6058
6318
|
];
|
|
6319
|
+
var ACTIVITY_STREAM_DEFAULT_MAX_ITEMS = 100;
|
|
6320
|
+
var ACTIVITY_STREAM_RETENTION_NOTE = "Canvas keeps activity-stream items for a limited window \u2014 typically the last ~4 weeks, set per Canvas instance (stream_items_ttl) and not readable through the API. Items older than that window are gone from this endpoint even though the underlying courses, discussions and submissions still exist, so an empty or short result is not evidence that nothing happened.";
|
|
6321
|
+
var PLANNER_ITEMS_DEFAULT_MAX_ITEMS = 100;
|
|
6322
|
+
var PLANNER_DEFAULT_WINDOW_DAYS = 14;
|
|
6323
|
+
var PLANNER_CONTEXT_CODE_PATTERN = /^(course|group)_[1-9][0-9]{0,18}$/;
|
|
6324
|
+
function isoDateAtUtcMidnight(daysFromNow) {
|
|
6325
|
+
const date = /* @__PURE__ */ new Date();
|
|
6326
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
6327
|
+
date.setUTCDate(date.getUTCDate() + daysFromNow);
|
|
6328
|
+
return date.toISOString();
|
|
6329
|
+
}
|
|
6059
6330
|
function classifyCommentAuthor(comment, submission) {
|
|
6060
6331
|
if (comment.author_id === submission.user_id) return "self";
|
|
6061
6332
|
if (submission.grader_id != null && comment.author_id === submission.grader_id) return "teacher";
|
|
@@ -6201,10 +6472,12 @@ function studentTools(canvas, pseudonymizer) {
|
|
|
6201
6472
|
const peerAuthors = /* @__PURE__ */ new Map();
|
|
6202
6473
|
for (const { courseId, submission } of candidates) {
|
|
6203
6474
|
for (const comment of submission.submission_comments ?? []) {
|
|
6475
|
+
const authorKey = submissionCommentAuthorKey(comment);
|
|
6476
|
+
if (authorKey === null) continue;
|
|
6204
6477
|
if (classifyCommentAuthor(comment, submission) === "peer") {
|
|
6205
|
-
peerAuthors.set(`${courseId}:${
|
|
6478
|
+
peerAuthors.set(`${courseId}:${authorKey}`, {
|
|
6206
6479
|
courseId,
|
|
6207
|
-
id:
|
|
6480
|
+
id: authorKey,
|
|
6208
6481
|
name: comment.author_name
|
|
6209
6482
|
});
|
|
6210
6483
|
}
|
|
@@ -6261,6 +6534,134 @@ function studentTools(canvas, pseudonymizer) {
|
|
|
6261
6534
|
findings
|
|
6262
6535
|
};
|
|
6263
6536
|
}
|
|
6537
|
+
},
|
|
6538
|
+
{
|
|
6539
|
+
name: "get_my_activity_stream_summary",
|
|
6540
|
+
title: "Get My Activity Stream Summary",
|
|
6541
|
+
description: `Count unread and total items in the authenticated student's cross-course activity stream, grouped by Canvas item type (e.g. DiscussionTopic, Announcement, Submission, Conversation). No item content \u2014 counts only. A good first call in a daily workflow to check "is there anything to look at?" before fetching the full stream.`,
|
|
6542
|
+
inputSchema: {
|
|
6543
|
+
only_active_courses: z29.boolean().optional().describe(
|
|
6544
|
+
"Only count activity in courses the student is actively participating in. Omit to count across all courses, including concluded ones."
|
|
6545
|
+
)
|
|
6546
|
+
},
|
|
6547
|
+
annotations: {
|
|
6548
|
+
readOnlyHint: true,
|
|
6549
|
+
openWorldHint: true
|
|
6550
|
+
},
|
|
6551
|
+
handler: async (params) => {
|
|
6552
|
+
const onlyActiveCourses = params.only_active_courses;
|
|
6553
|
+
return canvas.activityStream.getSummary(onlyActiveCourses);
|
|
6554
|
+
}
|
|
6555
|
+
},
|
|
6556
|
+
{
|
|
6557
|
+
name: "get_my_activity_stream",
|
|
6558
|
+
title: "Get My Activity Stream",
|
|
6559
|
+
description: "The authenticated student's cross-course activity stream: new discussion posts, announcements, conversation messages, graded submissions, peer-review requests and conference invitations from every course at once, newest first. Four caveats, none of them visible in the response: (1) RECENT ACTIVITY ONLY \u2014 typically the last ~4 weeks; the retention window is a per-Canvas-instance setting and cannot be read through the API, so \"nothing found\" is not evidence that nothing happened. (2) Each item's `message` was truncated to 4096 characters when Canvas stored it, with no marker \u2014 follow the item's `html_url` object with get_discussion or get_conversation for the full text. (3) At most 3 `root_discussion_entries` per discussion item and 3 `latest_messages` per conversation item. (4) Exactly one of `course_id` / `group_id` is present on each item and the other key is ABSENT, not null \u2014 join on `course_id`, and read `context_type` to tell which you have.",
|
|
6560
|
+
inputSchema: {
|
|
6561
|
+
only_active_courses: z29.boolean().optional().describe(
|
|
6562
|
+
"Only return activity in courses the student is actively participating in. Omit to include all courses, including concluded ones."
|
|
6563
|
+
),
|
|
6564
|
+
max_items: z29.number().int().positive().max(500).optional().describe(
|
|
6565
|
+
`Maximum items to return (default ${ACTIVITY_STREAM_DEFAULT_MAX_ITEMS}). When the limit is reached, \`truncated\` is true and \`truncation_note\` explains how to narrow the query.`
|
|
6566
|
+
)
|
|
6567
|
+
},
|
|
6568
|
+
annotations: {
|
|
6569
|
+
readOnlyHint: true,
|
|
6570
|
+
openWorldHint: true
|
|
6571
|
+
},
|
|
6572
|
+
handler: async (params) => {
|
|
6573
|
+
const onlyActiveCourses = params.only_active_courses;
|
|
6574
|
+
const limit = params.max_items ?? ACTIVITY_STREAM_DEFAULT_MAX_ITEMS;
|
|
6575
|
+
const fetched = await canvas.activityStream.getStream({
|
|
6576
|
+
onlyActiveCourses,
|
|
6577
|
+
maxItems: limit + 1
|
|
6578
|
+
});
|
|
6579
|
+
const truncated = fetched.length > limit;
|
|
6580
|
+
const page = truncated ? fetched.slice(0, limit) : fetched;
|
|
6581
|
+
const items = pseudonymizer?.isEnabled() ? await pseudonymizer.anonymizeActivityStream(page) : page;
|
|
6582
|
+
return {
|
|
6583
|
+
items,
|
|
6584
|
+
total_items: items.length,
|
|
6585
|
+
truncated,
|
|
6586
|
+
truncation_note: truncated ? `Stopped after max_items (${limit}) items; more recent activity exists. Raise max_items (maximum 500), or set only_active_courses to true to drop concluded courses. get_my_activity_stream_summary gives per-type counts for the whole window without fetching items.` : null,
|
|
6587
|
+
// Unconditional on purpose. `truncated` answers "did WE stop early?";
|
|
6588
|
+
// it cannot answer "did Canvas already forget?", and that horizon is
|
|
6589
|
+
// an instance Setting invisible to the API — there is no value to
|
|
6590
|
+
// compute and no condition under which the flag could be set
|
|
6591
|
+
// correctly. A nullable note would read as "not truncated by
|
|
6592
|
+
// retention", which this server can never assert.
|
|
6593
|
+
retention_note: ACTIVITY_STREAM_RETENTION_NOTE
|
|
6594
|
+
};
|
|
6595
|
+
}
|
|
6596
|
+
},
|
|
6597
|
+
{
|
|
6598
|
+
name: "list_my_planner_items",
|
|
6599
|
+
title: "List My Planner Items",
|
|
6600
|
+
description: 'The authenticated student\'s planner: assignments, ungraded quizzes, planner notes, wiki pages, ungraded discussions, calendar events, peer reviews, sub-assignments and peer-review sub-assignments \u2014 everything with a due or to-do date, across every course and group at once. Strictly richer than get_todo_items, which covers only assignments needing submitting or grading. Four caveats: (1) start_date and end_date must be given TOGETHER or OMITTED TOGETHER \u2014 supplying only one makes Canvas silently default the other side to a ten-year window, so a half-specified range is rejected outright; omitting both resolves to Canvas\'s own ~2-week default, echoed back as the response\'s `start_date`/`end_date`. (2) `context_codes` (e.g. "course_123", "group_7") restricts to those contexts; omit it for every context the student belongs to \u2014 an empty array is rejected rather than silently meaning "all". (3) `submissions` is `false` when the item has no gradable submission, or an object when it does \u2014 never null. (4) a planner note has no `html_url` (Canvas exposes none for individual planner notes).',
|
|
6601
|
+
inputSchema: {
|
|
6602
|
+
start_date: z29.string().optional().describe(
|
|
6603
|
+
"Inclusive start, YYYY-MM-DD or ISO 8601. Must be given together with end_date."
|
|
6604
|
+
),
|
|
6605
|
+
end_date: z29.string().optional().describe(
|
|
6606
|
+
"Inclusive end, YYYY-MM-DD or ISO 8601. Must be given together with start_date."
|
|
6607
|
+
),
|
|
6608
|
+
context_codes: z29.array(
|
|
6609
|
+
z29.string().regex(
|
|
6610
|
+
PLANNER_CONTEXT_CODE_PATTERN,
|
|
6611
|
+
'Each context code must be "course_<id>" or "group_<id>" (no leading zeros)'
|
|
6612
|
+
)
|
|
6613
|
+
).min(
|
|
6614
|
+
1,
|
|
6615
|
+
"context_codes must not be empty \u2014 omit the field for every context the student belongs to, rather than sending an empty list."
|
|
6616
|
+
).optional().describe(
|
|
6617
|
+
'Restrict to these contexts, e.g. ["course_123","group_7"]. Defaults to every context the student belongs to.'
|
|
6618
|
+
),
|
|
6619
|
+
filter: z29.enum(["new_activity", "incomplete_items", "complete_items"]).optional().describe(
|
|
6620
|
+
"new_activity = unread/new only; incomplete_items / complete_items filter on planner-override completion and submission state."
|
|
6621
|
+
),
|
|
6622
|
+
max_items: z29.number().int().positive().max(500).optional().describe(
|
|
6623
|
+
`Maximum items to return (default ${PLANNER_ITEMS_DEFAULT_MAX_ITEMS}). When the limit is reached, \`truncated\` is true and \`truncation_note\` explains how to narrow the query.`
|
|
6624
|
+
)
|
|
6625
|
+
},
|
|
6626
|
+
annotations: {
|
|
6627
|
+
readOnlyHint: true,
|
|
6628
|
+
openWorldHint: true
|
|
6629
|
+
},
|
|
6630
|
+
handler: async (params) => {
|
|
6631
|
+
const startDate = params.start_date;
|
|
6632
|
+
const endDate = params.end_date;
|
|
6633
|
+
if (startDate === void 0 !== (endDate === void 0)) {
|
|
6634
|
+
throw new Error(
|
|
6635
|
+
"start_date and end_date must be given together, or neither. Canvas fills a missing side with its own 10-year default (10 years ago or 10 years from now), silently producing a window up to a decade wide \u2014 supply both explicitly, or omit both to use Canvas's ~2-week default."
|
|
6636
|
+
);
|
|
6637
|
+
}
|
|
6638
|
+
const contextCodes = params.context_codes;
|
|
6639
|
+
const filter = params.filter;
|
|
6640
|
+
const limit = params.max_items ?? PLANNER_ITEMS_DEFAULT_MAX_ITEMS;
|
|
6641
|
+
const fetched = await canvas.planner.listItems({
|
|
6642
|
+
startDate,
|
|
6643
|
+
endDate,
|
|
6644
|
+
contextCodes,
|
|
6645
|
+
filter,
|
|
6646
|
+
maxItems: limit + 1
|
|
6647
|
+
});
|
|
6648
|
+
const truncated = fetched.length > limit;
|
|
6649
|
+
const capped = truncated ? fetched.slice(0, limit) : fetched;
|
|
6650
|
+
const items = pseudonymizer?.isEnabled() ? await pseudonymizer.anonymizePlannerItems(capped) : capped;
|
|
6651
|
+
return {
|
|
6652
|
+
items,
|
|
6653
|
+
total_items: items.length,
|
|
6654
|
+
truncated,
|
|
6655
|
+
truncation_note: truncated ? `Stopped after max_items (${limit}) items; more planner items exist in this window. Raise max_items (maximum 500), or narrow start_date/end_date or context_codes.` : null,
|
|
6656
|
+
// Resolved window: when the caller passed neither date, Canvas
|
|
6657
|
+
// applies its own ±2-week default server-side and never echoes it
|
|
6658
|
+
// back. This is computed independently rather than read off the
|
|
6659
|
+
// response, so it is an approximation of what Canvas used, not a
|
|
6660
|
+
// read of it (BRU-2797 §7.1).
|
|
6661
|
+
start_date: startDate ?? isoDateAtUtcMidnight(-PLANNER_DEFAULT_WINDOW_DAYS),
|
|
6662
|
+
end_date: endDate ?? isoDateAtUtcMidnight(PLANNER_DEFAULT_WINDOW_DAYS)
|
|
6663
|
+
};
|
|
6664
|
+
}
|
|
6264
6665
|
}
|
|
6265
6666
|
];
|
|
6266
6667
|
}
|
|
@@ -9620,4 +10021,4 @@ export {
|
|
|
9620
10021
|
createSharedPseudonymizer,
|
|
9621
10022
|
createCanvasMCPServer
|
|
9622
10023
|
};
|
|
9623
|
-
//# sourceMappingURL=chunk-
|
|
10024
|
+
//# sourceMappingURL=chunk-FKTJGYLG.js.map
|