brightspace-mcp-server 3.9.20 → 3.9.21

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.
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems } from "../api/paginate.js";
8
8
  import { GetAnnouncementsSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
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";
@@ -97,22 +97,53 @@ export function registerGetAnnouncements(server, apiClient, config) {
97
97
  const cutoff = modifiedSince ? new Date(modifiedSince) : null;
98
98
  // Single course case
99
99
  if (courseId) {
100
- const newsItems = await fetchCourseNews(apiClient, courseId);
101
- // Drop drafts, then map to clean objects
102
- const published = newsItems.filter(isPublishedNewsItem).map(mapNewsItem);
103
- const matched = cutoff
104
- ? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
105
- : published;
106
- const announcements = matched.sort(newestFirst).slice(0, count);
107
- log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
108
- return toolResponse(modifiedSince
109
- ? {
110
- announcements,
111
- modifiedSince,
112
- returned: announcements.length,
113
- filteredOut: published.length - matched.length,
100
+ try {
101
+ const newsItems = await fetchCourseNews(apiClient, courseId);
102
+ // Drop drafts, then map to clean objects
103
+ const published = newsItems.filter(isPublishedNewsItem).map(mapNewsItem);
104
+ const matched = cutoff
105
+ ? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
106
+ : published;
107
+ const announcements = matched.sort(newestFirst).slice(0, count);
108
+ log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
109
+ return toolResponse(modifiedSince
110
+ ? {
111
+ announcements,
112
+ modifiedSince,
113
+ returned: announcements.length,
114
+ filteredOut: published.length - matched.length,
115
+ }
116
+ : announcements);
117
+ }
118
+ catch (error) {
119
+ // A pending sign-in is not an empty course: the route never
120
+ // answered, so the result says so instead of reporting zero
121
+ // announcements as if that were a real measurement.
122
+ if (isAuthUnavailable(error)) {
123
+ log("DEBUG", `get_announcements: sign-in pending for course ${courseId}`, error);
124
+ const notice = "Sign-in to Brightspace is still in progress, so announcements for this course " +
125
+ `could not be fetched yet. ${authPendingNotice(error)} Call get_announcements again ` +
126
+ "once sign-in finishes.";
127
+ // With modifiedSince the response is already an object (that is
128
+ // the shape those callers already get), so authPending/notice
129
+ // join the rest of the JSON there. Without it the response is
130
+ // always a bare array — a shape change would break every
131
+ // existing caller — so the empty array stays content[0] and the
132
+ // notice becomes a second content block instead.
133
+ if (modifiedSince) {
134
+ return toolResponse({
135
+ announcements: [],
136
+ modifiedSince,
137
+ returned: 0,
138
+ filteredOut: 0,
139
+ authPending: true,
140
+ notice,
141
+ });
142
+ }
143
+ return toolResponseWithNotice([], notice);
114
144
  }
115
- : announcements);
145
+ throw error;
146
+ }
116
147
  }
117
148
  // All courses case
118
149
  // First, fetch enrolled courses. isActive=true is the configured
@@ -152,13 +183,24 @@ export function registerGetAnnouncements(server, apiClient, config) {
152
183
  log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
153
184
  return [];
154
185
  }
186
+ // A pending sign-in only means this course's route never
187
+ // answered — it says nothing about the other courses, whose
188
+ // requests may already have gone out independently. Mark this
189
+ // one rather than failing the whole call and losing every
190
+ // course that *did* answer.
191
+ if (isAuthUnavailable(error)) {
192
+ log("DEBUG", `get_announcements: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
193
+ return { authPending: true, courseId: item.OrgUnit.Id, authError: error };
194
+ }
155
195
  throw error; // Re-throw other errors
156
196
  }
157
197
  });
158
198
  const results = await Promise.allSettled(announcementPromises);
159
- const allAnnouncements = results
199
+ const settled = results
160
200
  .filter((r) => r.status === "fulfilled")
161
- .flatMap((r) => r.value);
201
+ .map((r) => r.value);
202
+ const pending = settled.filter((v) => !Array.isArray(v) && v?.authPending === true);
203
+ const allAnnouncements = settled.filter((v) => Array.isArray(v)).flat();
162
204
  const allMatched = cutoff
163
205
  ? allAnnouncements.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
164
206
  : allAnnouncements;
@@ -166,15 +208,38 @@ export function registerGetAnnouncements(server, apiClient, config) {
166
208
  const announcements = allMatched
167
209
  .sort(newestFirst)
168
210
  .slice(0, count);
169
- log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total across ${enrollmentItems.length} courses)`);
170
- return toolResponse(modifiedSince
171
- ? {
172
- announcements,
173
- modifiedSince,
174
- returned: announcements.length,
175
- filteredOut: allAnnouncements.length - allMatched.length,
176
- }
177
- : announcements);
211
+ log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total ` +
212
+ `across ${enrollmentItems.length} courses${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
213
+ // With modifiedSince the response is already an object (the shape
214
+ // those callers already get), so authPending/unavailableCourseIds/
215
+ // notice join the rest of the JSON there. Without it the response is
216
+ // always a bare array — a shape change would break every existing
217
+ // caller — so a pending course adds a second content block carrying
218
+ // the notice instead of changing content[0].
219
+ if (!modifiedSince) {
220
+ if (pending.length === 0)
221
+ return toolResponse(announcements);
222
+ return toolResponseWithNotice(announcements, "Sign-in to Brightspace is still in progress, so announcements for " +
223
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
224
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
225
+ "sign-in finishes.");
226
+ }
227
+ const response = {
228
+ announcements,
229
+ modifiedSince,
230
+ returned: announcements.length,
231
+ filteredOut: allAnnouncements.length - allMatched.length,
232
+ };
233
+ if (pending.length > 0) {
234
+ response.authPending = true;
235
+ response.unavailableCourseIds = pending.map((c) => c.courseId);
236
+ response.notice =
237
+ "Sign-in to Brightspace is still in progress, so announcements for " +
238
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
239
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
240
+ "sign-in finishes.";
241
+ }
242
+ return toolResponse(response);
178
243
  }
179
244
  catch (error) {
180
245
  return sanitizeError(error);
@@ -4,7 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { GetCalendarEventsSchema } from "./schemas.js";
7
- import { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
7
+ import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
8
8
  import { log } from "../utils/logger.js";
9
9
  import { resolveCourses } from "./resolve-courses.js";
10
10
  import { fetchCourseCalendarEvents } from "./calendar-events.js";
@@ -31,17 +31,39 @@ export function registerGetCalendarEvents(server, apiClient, config) {
31
31
  }
32
32
  const courses = await resolveCourses(apiClient, config, courseId);
33
33
  const results = await Promise.allSettled(courses.map((course) => fetchCourseCalendarEvents(apiClient, config.baseUrl, course, windowStart, windowEnd)));
34
+ // A pending sign-in only means that course's route never answered —
35
+ // it says nothing about the other courses, whose requests may already
36
+ // have gone out independently. Collect it rather than failing the
37
+ // whole call and losing every course that *did* answer.
38
+ const pendingCourseIds = [];
39
+ let firstAuthError = null;
34
40
  const events = results
35
41
  .flatMap((result, i) => {
36
42
  if (result.status === "fulfilled")
37
43
  return result.value;
44
+ if (isAuthUnavailable(result.reason)) {
45
+ pendingCourseIds.push(courses[i].id);
46
+ firstAuthError ??= result.reason;
47
+ log("DEBUG", `get_calendar_events: sign-in pending for course ${courses[i].id}`, result.reason);
48
+ return [];
49
+ }
38
50
  log("DEBUG", `get_calendar_events: skipping course ${courses[i].id} after fetch failure`, result.reason);
39
51
  return [];
40
52
  })
41
53
  .filter((event) => includeGenerated || !event.generatedFrom)
42
54
  .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
43
- log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses`);
44
- return toolResponse(events);
55
+ log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses` +
56
+ (pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : ""));
57
+ // The response is always a bare array, pending sign-in or not — a
58
+ // shape change would break every existing caller. A pending course
59
+ // instead adds a second content block carrying the notice, which
60
+ // names which course ids are unavailable.
61
+ if (pendingCourseIds.length === 0) {
62
+ return toolResponse(events);
63
+ }
64
+ return toolResponseWithNotice(events, "Sign-in to Brightspace is still in progress, so calendar events for " +
65
+ `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
66
+ `yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
45
67
  }
46
68
  catch (error) {
47
69
  return sanitizeError(error);
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllItems } from "../api/paginate.js";
8
8
  import { GetMyGradesSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { gradebookUrl } from "../utils/deep-links.js";
@@ -45,6 +45,23 @@ export function registerGetMyGrades(server, apiClient, config) {
45
45
  return errorResponse("Your institution restricts grade API access for this course. " +
46
46
  `View your grades directly in Brightspace: ${url}`);
47
47
  }
48
+ // A pending sign-in is not an empty gradebook: the route never
49
+ // answered, so the result says so instead of reporting zero grades
50
+ // as if that were a real measurement. The envelope stays a
51
+ // success — existing callers that only read `grades` keep
52
+ // working — with authPending/notice added for callers that want
53
+ // to tell "no grades" apart from "couldn't check".
54
+ if (isAuthUnavailable(error)) {
55
+ log("DEBUG", `get_my_grades: sign-in pending for course ${courseId}`, error);
56
+ return toolResponse({
57
+ courseId,
58
+ grades: [],
59
+ authPending: true,
60
+ notice: "Sign-in to Brightspace is still in progress, so grades for this course " +
61
+ `could not be fetched yet. ${authPendingNotice(error)} Call get_my_grades again ` +
62
+ "once sign-in finishes.",
63
+ });
64
+ }
48
65
  throw error;
49
66
  }
50
67
  // Map to clean objects
@@ -120,6 +137,20 @@ export function registerGetMyGrades(server, apiClient, config) {
120
137
  gradeUrl: gradebookUrl(config.baseUrl, item.OrgUnit.Id),
121
138
  };
122
139
  }
140
+ // A pending sign-in only means this course's route never
141
+ // answered — it says nothing about the other courses, whose
142
+ // requests may already have gone out independently. Mark this one
143
+ // rather than failing the whole call and losing every course that
144
+ // *did* answer.
145
+ if (isAuthUnavailable(error)) {
146
+ log("DEBUG", `get_my_grades: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
147
+ return {
148
+ courseId: item.OrgUnit.Id,
149
+ courseName: item.OrgUnit.Name,
150
+ authPending: true,
151
+ authError: error,
152
+ };
153
+ }
123
154
  throw error; // Re-throw other errors
124
155
  }
125
156
  });
@@ -127,15 +158,42 @@ export function registerGetMyGrades(server, apiClient, config) {
127
158
  const settled = results
128
159
  .filter((r) => r.status === "fulfilled")
129
160
  .map((r) => r.value);
130
- const courses = settled
131
- .filter((r) => !r.restricted)
132
- .map(({ restricted: _restricted, ...rest }) => rest);
161
+ const pending = settled.filter((c) => c.authPending);
133
162
  const restrictedCourses = settled
134
- .filter((r) => r.restricted)
163
+ .filter((c) => c.restricted)
135
164
  .map(({ restricted: _restricted, ...rest }) => rest);
165
+ const courses = settled
166
+ .filter((c) => !c.authPending && !c.restricted)
167
+ .map(({ courseId, courseName, grades }) => ({ courseId, courseName, grades }));
136
168
  log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
137
- `${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled)`);
138
- return toolResponse({ courses, restrictedCourses });
169
+ `${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled` +
170
+ `${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
171
+ // Pending courses keep `grades: []` so callers that read
172
+ // `courses[i].grades` still get an array; authPending marks it as
173
+ // unchecked rather than empty. `restrictedCourses` is always present
174
+ // (even empty) so callers can rely on the field existing.
175
+ const response = {
176
+ courses: [
177
+ ...courses,
178
+ ...pending.map(({ courseId, courseName }) => ({
179
+ courseId,
180
+ courseName,
181
+ grades: [],
182
+ authPending: true,
183
+ })),
184
+ ],
185
+ restrictedCourses,
186
+ };
187
+ if (pending.length > 0) {
188
+ response.authPending = true;
189
+ response.unavailableCourseIds = pending.map((c) => c.courseId);
190
+ response.notice =
191
+ "Sign-in to Brightspace is still in progress, so grades for " +
192
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
193
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_my_grades again once ` +
194
+ "sign-in finishes.";
195
+ }
196
+ return toolResponse(response);
139
197
  }
140
198
  catch (error) {
141
199
  return sanitizeError(error);
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetRosterSchema, } from "./schemas.js";
9
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  // Purdue-specific role IDs. These are institution-specific values.
12
12
  // If using at another institution, you may need to adjust these.
@@ -55,91 +55,134 @@ export function registerGetRoster(server, apiClient) {
55
55
  log("DEBUG", "get_roster tool called", { args });
56
56
  // Parse and validate input
57
57
  const { courseId, includeStudents, searchTerm, limit } = GetRosterSchema.parse(args);
58
- const allUsers = [];
59
- let roleFallbackUsed = false;
60
- if (!includeStudents) {
61
- // Fetch instructors and TAs in parallel
62
- const [instructorResult, taResult] = await Promise.allSettled([
63
- fetchClasslistUsers(apiClient, courseId, {
64
- roleId: INSTRUCTOR_ROLE_ID,
65
- searchTerm,
66
- }),
67
- fetchClasslistUsers(apiClient, courseId, {
68
- roleId: TA_ROLE_ID,
69
- searchTerm,
70
- }),
71
- ]);
72
- // Merge results
73
- if (instructorResult.status === "fulfilled") {
74
- allUsers.push(...instructorResult.value);
75
- }
76
- else {
77
- log("WARN", "get_roster: Failed to fetch instructors", {
78
- error: instructorResult.reason,
79
- });
80
- }
81
- if (taResult.status === "fulfilled") {
82
- allUsers.push(...taResult.value);
58
+ try {
59
+ const allUsers = [];
60
+ let authFailure;
61
+ let roleFallbackUsed = false;
62
+ if (!includeStudents) {
63
+ // Fetch instructors and TAs in parallel
64
+ const [instructorResult, taResult] = await Promise.allSettled([
65
+ fetchClasslistUsers(apiClient, courseId, {
66
+ roleId: INSTRUCTOR_ROLE_ID,
67
+ searchTerm,
68
+ }),
69
+ fetchClasslistUsers(apiClient, courseId, {
70
+ roleId: TA_ROLE_ID,
71
+ searchTerm,
72
+ }),
73
+ ]);
74
+ // A pending sign-in means that route can't be trusted to have
75
+ // actually asked Brightspace anything — unlike a role group that
76
+ // is genuinely empty, which is a real measurement. Note it rather
77
+ // than quietly reporting half the roster as the whole answer, but
78
+ // keep whichever route *did* answer instead of discarding it: a
79
+ // roster that lost its TAs to a pending sign-in should still show
80
+ // the instructor it already has.
81
+ authFailure = [instructorResult, taResult].find((r) => r.status === "rejected" && isAuthUnavailable(r.reason));
82
+ // Merge results
83
+ if (instructorResult.status === "fulfilled") {
84
+ allUsers.push(...instructorResult.value);
85
+ }
86
+ else {
87
+ log("WARN", "get_roster: Failed to fetch instructors", {
88
+ error: instructorResult.reason,
89
+ });
90
+ }
91
+ if (taResult.status === "fulfilled") {
92
+ allUsers.push(...taResult.value);
93
+ }
94
+ else {
95
+ log("WARN", "get_roster: Failed to fetch TAs", {
96
+ error: taResult.reason,
97
+ });
98
+ }
99
+ // Purdue's role IDs found nobody — on a non-Purdue tenant they
100
+ // likely mean a different role, or none. Fall back to one
101
+ // unfiltered classlist fetch and match on the role display name
102
+ // the tenant itself reports instead of an institution-specific ID.
103
+ // A genuine failure of this fetch is intentionally not caught
104
+ // here: it should surface as a tool error rather than silently
105
+ // producing an empty staff list.
106
+ if (allUsers.length === 0) {
107
+ const everyone = await fetchClasslistUsers(apiClient, courseId, {
108
+ searchTerm,
109
+ });
110
+ allUsers.push(...everyone.filter(isTeachingRoleByName));
111
+ roleFallbackUsed = true;
112
+ }
83
113
  }
84
114
  else {
85
- log("WARN", "get_roster: Failed to fetch TAs", {
86
- error: taResult.reason,
87
- });
115
+ // Fetch all users
116
+ allUsers.push(...(await fetchClasslistUsers(apiClient, courseId, { searchTerm })));
88
117
  }
89
- // Purdue's role IDs found nobody — on a non-Purdue tenant they
90
- // likely mean a different role, or none. Fall back to one
91
- // unfiltered classlist fetch and match on the role display name
92
- // the tenant itself reports instead of an institution-specific ID.
93
- // A genuine failure of this fetch is intentionally not caught
94
- // here: it should surface as a tool error rather than silently
95
- // producing an empty staff list.
96
- if (allUsers.length === 0) {
97
- const everyone = await fetchClasslistUsers(apiClient, courseId, {
98
- searchTerm,
118
+ // A very large roster would swamp the response, so it is capped. The
119
+ // cap is reported in the payload rather than only in a log line the
120
+ // model never sees: a 340 person lecture used to look like a 100
121
+ // person one, with nothing to say otherwise.
122
+ const total = allUsers.length;
123
+ const truncated = total > limit;
124
+ const kept = truncated ? allUsers.slice(0, limit) : allUsers;
125
+ if (truncated) {
126
+ log("WARN", "get_roster: Result set exceeds the limit, truncating", {
127
+ total,
128
+ returned: kept.length,
99
129
  });
100
- allUsers.push(...everyone.filter(isTeachingRoleByName));
101
- roleFallbackUsed = true;
102
130
  }
103
- }
104
- else {
105
- // Fetch all users
106
- allUsers.push(...(await fetchClasslistUsers(apiClient, courseId, { searchTerm })));
107
- }
108
- // A very large roster would swamp the response, so it is capped. The
109
- // cap is reported in the payload rather than only in a log line the
110
- // model never sees: a 340 person lecture used to look like a 100
111
- // person one, with nothing to say otherwise.
112
- const total = allUsers.length;
113
- const truncated = total > limit;
114
- const kept = truncated ? allUsers.slice(0, limit) : allUsers;
115
- if (truncated) {
116
- log("WARN", "get_roster: Result set exceeds the limit, truncating", {
131
+ // Map to clean output
132
+ const users = kept.map((user) => ({
133
+ name: user.DisplayName,
134
+ email: user.Email || null,
135
+ role: user.ClasslistRoleDisplayName,
136
+ }));
137
+ log("INFO", `get_roster: Retrieved ${users.length} users for course ${courseId}` +
138
+ (authFailure ? " (sign-in pending for one route)" : ""));
139
+ return toolResponse({
140
+ courseId,
117
141
  total,
118
- returned: kept.length,
142
+ returned: users.length,
143
+ truncated,
144
+ ...(truncated
145
+ ? { note: `Showing ${users.length} of ${total}. Raise the limit argument to see more.` }
146
+ : {}),
147
+ ...(roleFallbackUsed
148
+ ? {
149
+ roleFilter: "No users matched the default instructor/TA role IDs, so staff were found by matching role display names (instructor, professor, lecturer, teaching assistant, coordinator, grader, TA) instead.",
150
+ }
151
+ : {}),
152
+ users,
153
+ ...(authFailure
154
+ ? {
155
+ authPending: true,
156
+ notice: "Sign-in to Brightspace is still in progress, so part of the roster for this " +
157
+ `course could not be fetched yet. ${authPendingNotice(authFailure.reason)} Call ` +
158
+ "get_roster again once sign-in finishes.",
159
+ }
160
+ : {}),
119
161
  });
120
162
  }
121
- // Map to clean output
122
- const users = kept.map((user) => ({
123
- name: user.DisplayName,
124
- email: user.Email || null,
125
- role: user.ClasslistRoleDisplayName,
126
- }));
127
- log("INFO", `get_roster: Retrieved ${users.length} users for course ${courseId}`);
128
- return toolResponse({
129
- courseId,
130
- total,
131
- returned: users.length,
132
- truncated,
133
- ...(truncated
134
- ? { note: `Showing ${users.length} of ${total}. Raise the limit argument to see more.` }
135
- : {}),
136
- ...(roleFallbackUsed
137
- ? {
138
- roleFilter: "No users matched the default instructor/TA role IDs, so staff were found by matching role display names (instructor, professor, lecturer, teaching assistant, coordinator, grader, TA) instead.",
139
- }
140
- : {}),
141
- users,
142
- });
163
+ catch (error) {
164
+ // A pending sign-in is not an empty roster: the route never
165
+ // answered, so the result says so instead of reporting zero users
166
+ // as if that were a real measurement. The envelope stays a success
167
+ // — existing callers that only read `users` keep working — with
168
+ // authPending/notice added for callers that want to tell "empty
169
+ // roster" apart from "couldn't check".
170
+ if (isAuthUnavailable(error)) {
171
+ log("DEBUG", `get_roster: sign-in pending for course ${courseId}`, error);
172
+ return toolResponse({
173
+ courseId,
174
+ total: 0,
175
+ returned: 0,
176
+ truncated: false,
177
+ users: [],
178
+ authPending: true,
179
+ notice: "Sign-in to Brightspace is still in progress, so the roster for this course " +
180
+ `could not be fetched yet. ${authPendingNotice(error)} Call get_roster again once ` +
181
+ "sign-in finishes.",
182
+ });
183
+ }
184
+ throw error;
185
+ }
143
186
  }
144
187
  catch (error) {
145
188
  return sanitizeError(error);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
8
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
8
+ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
10
  import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
11
11
  import { dueIn } from "../utils/due-in.js";
@@ -35,6 +35,8 @@ async function fetchDiscussionDueTopics(apiClient, courseId) {
35
35
  topics.push(...unwrapList(forumTopics));
36
36
  }
37
37
  catch (error) {
38
+ if (isAuthUnavailable(error))
39
+ throw error;
38
40
  log("DEBUG", `get_upcoming_due_dates: failed to fetch topics for forum ${forum.ForumId} in course ${courseId}`, error);
39
41
  }
40
42
  }
@@ -79,6 +81,12 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
79
81
  fetchDiscussionDueTopics(apiClient, course.id),
80
82
  fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to),
81
83
  ]);
84
+ // Each route may fail on its own without costing the others, but a failed
85
+ // sign-in means none of them answered: report that, not an empty course.
86
+ for (const result of [dropboxResult, quizResult, discussionResult, calendarResult]) {
87
+ if (result.status === "rejected" && isAuthUnavailable(result.reason))
88
+ throw result.reason;
89
+ }
82
90
  const items = [];
83
91
  if (dropboxResult.status === "fulfilled") {
84
92
  for (const folder of unwrapList(dropboxResult.value)) {
@@ -180,9 +188,21 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
180
188
  log("DEBUG", `get_upcoming_due_dates: querying ${courses.length} course(s), window=${daysAhead} days`);
181
189
  // Fetch every course in parallel; the API client rate limits itself
182
190
  const results = await Promise.allSettled(courses.map((course) => fetchCourseDueItems(apiClient, config.baseUrl, course, now, windowEnd)));
183
- const items = results.flatMap((result) => {
191
+ // A pending sign-in only means that course's routes never answered —
192
+ // it says nothing about the other courses, whose requests may already
193
+ // have gone out independently. Collect it rather than failing the
194
+ // whole call and losing every course that *did* answer.
195
+ const pendingCourseIds = [];
196
+ let firstAuthError = null;
197
+ const items = results.flatMap((result, i) => {
184
198
  if (result.status === "fulfilled")
185
199
  return result.value;
200
+ if (isAuthUnavailable(result.reason)) {
201
+ pendingCourseIds.push(courses[i].id);
202
+ firstAuthError ??= result.reason;
203
+ log("DEBUG", `get_upcoming_due_dates: sign-in pending for course ${courses[i].id}`, result.reason);
204
+ return [];
205
+ }
186
206
  log("DEBUG", "get_upcoming_due_dates: skipping course after fetch failure", result.reason);
187
207
  return [];
188
208
  });
@@ -193,8 +213,18 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
193
213
  return Number.isFinite(due) && due >= now && due <= windowEnd;
194
214
  })
195
215
  .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
196
- log("INFO", `get_upcoming_due_dates: Retrieved ${upcoming.length} items across ${courses.length} courses`);
197
- return toolResponse(upcoming);
216
+ log("INFO", `get_upcoming_due_dates: Retrieved ${upcoming.length} items across ${courses.length} courses` +
217
+ (pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : ""));
218
+ // The response is always a bare array, pending sign-in or not — a
219
+ // shape change would break every existing caller. A pending course
220
+ // instead adds a second content block carrying the notice, which
221
+ // names which course ids are unavailable.
222
+ if (pendingCourseIds.length === 0) {
223
+ return toolResponse(upcoming);
224
+ }
225
+ return toolResponseWithNotice(upcoming, "Sign-in to Brightspace is still in progress, so upcoming due dates for " +
226
+ `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
227
+ `yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
198
228
  }
199
229
  catch (error) {
200
230
  return sanitizeError(error);
@@ -42,6 +42,22 @@ export function toolResponse(data) {
42
42
  },
43
43
  ]);
44
44
  }
45
+ /**
46
+ * Wrap data as an MCP-compatible tool result, with a notice appended as a
47
+ * second content block.
48
+ *
49
+ * Some callers already depend on content[0] being a specific shape (a bare
50
+ * array, for instance) and must not see that shape change just because there
51
+ * is something to tell the user — a pending sign-in, say. This keeps
52
+ * content[0] exactly what toolResponse(data) alone would have produced and
53
+ * puts `notice` in content[1], ahead of (and separate from) any update notice
54
+ * toolResponse would itself append.
55
+ */
56
+ export function toolResponseWithNotice(data, notice) {
57
+ const result = toolResponse(data);
58
+ result.content.push({ type: "text", text: notice });
59
+ return result;
60
+ }
45
61
  /**
46
62
  * Wrap error message as MCP-compatible tool result
47
63
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.20",
3
+ "version": "3.9.21",
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",