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.
Files changed (44) hide show
  1. package/README.md +5 -5
  2. package/dist/canvas/activity-stream.d.ts +35 -0
  3. package/dist/canvas/activity-stream.d.ts.map +1 -0
  4. package/dist/canvas/client.d.ts +3 -1
  5. package/dist/canvas/client.d.ts.map +1 -1
  6. package/dist/canvas/index.cjs +3 -3
  7. package/dist/canvas/index.d.ts +4 -0
  8. package/dist/canvas/index.d.ts.map +1 -1
  9. package/dist/canvas/index.js +2 -2
  10. package/dist/canvas/planner.d.ts +31 -0
  11. package/dist/canvas/planner.d.ts.map +1 -0
  12. package/dist/canvas/types.d.ts +326 -1
  13. package/dist/canvas/types.d.ts.map +1 -1
  14. package/dist/{chunk-KNYGBEL5.cjs → chunk-C2C3TYWD.cjs} +16 -8
  15. package/dist/chunk-C2C3TYWD.cjs.map +1 -0
  16. package/dist/{chunk-66PSR2DZ.cjs → chunk-EMIO4XD4.cjs} +75 -4
  17. package/dist/chunk-EMIO4XD4.cjs.map +1 -0
  18. package/dist/{chunk-KHJEIVEQ.js → chunk-FKTJGYLG.js} +410 -9
  19. package/dist/chunk-FKTJGYLG.js.map +1 -0
  20. package/dist/{chunk-5B3JDDYC.js → chunk-FP4A34TD.js} +73 -2
  21. package/dist/chunk-FP4A34TD.js.map +1 -0
  22. package/dist/{chunk-AWI5UBRV.cjs → chunk-JR74DYXY.cjs} +805 -404
  23. package/dist/chunk-JR74DYXY.cjs.map +1 -0
  24. package/dist/{chunk-VVIZW7HE.js → chunk-W5DF5YBG.js} +13 -5
  25. package/dist/{chunk-VVIZW7HE.js.map → chunk-W5DF5YBG.js.map} +1 -1
  26. package/dist/http.cjs +6 -6
  27. package/dist/http.js +3 -3
  28. package/dist/init.cjs +3 -3
  29. package/dist/init.js +1 -1
  30. package/dist/provenance/fields.d.ts.map +1 -1
  31. package/dist/pseudonym/coverage.d.ts.map +1 -1
  32. package/dist/pseudonym/pseudonymizer.d.ts +118 -1
  33. package/dist/pseudonym/pseudonymizer.d.ts.map +1 -1
  34. package/dist/server.cjs +4 -4
  35. package/dist/server.js +3 -3
  36. package/dist/stdio.cjs +4 -4
  37. package/dist/stdio.js +3 -3
  38. package/dist/tools/student.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/dist/chunk-5B3JDDYC.js.map +0 -1
  41. package/dist/chunk-66PSR2DZ.cjs.map +0 -1
  42. package/dist/chunk-AWI5UBRV.cjs.map +0 -1
  43. package/dist/chunk-KHJEIVEQ.js.map +0 -1
  44. 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-5B3JDDYC.js";
28
+ } from "./chunk-FP4A34TD.js";
29
29
  import {
30
30
  CanvasApiError,
31
31
  version
32
- } from "./chunk-VVIZW7HE.js";
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 pseudonym = map?.students[String(c.author_id)]?.pseudonym;
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
- out.push({ ...c, author_name: pseudonym });
403
- } else {
404
- out.push({ ...c });
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}:${comment.author_id}`, {
6478
+ peerAuthors.set(`${courseId}:${authorKey}`, {
6206
6479
  courseId,
6207
- id: comment.author_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-KHJEIVEQ.js.map
10024
+ //# sourceMappingURL=chunk-FKTJGYLG.js.map