infinitecampus-mcp 3.1.9 → 3.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Infinite Campus (Campus Parent) — grades, attendance, assignments, messages, documents",
10
- "version": "3.1.9"
10
+ "version": "3.2.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Infinite Campus",
16
16
  "source": "./",
17
17
  "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents via natural language",
18
- "version": "3.1.9",
18
+ "version": "3.2.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
3
  "displayName": "Infinite Campus",
4
- "version": "3.1.9",
4
+ "version": "3.2.0",
5
5
  "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -27078,7 +27078,7 @@ function declaredDialect(schema, remedy) {
27078
27078
  throw new Error(`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}`);
27079
27079
  }
27080
27080
 
27081
- // node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
27081
+ // node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
27082
27082
  var z = __toESM(require_v4(), 1);
27083
27083
  var LATEST_PROTOCOL_VERSION = "2025-11-25";
27084
27084
  var SUPPORTED_PROTOCOL_VERSIONS = [
@@ -68700,7 +68700,7 @@ function resolveDownloadDir(env = process.env) {
68700
68700
  // package.json
68701
68701
  var package_default = {
68702
68702
  name: "infinitecampus-mcp",
68703
- version: "3.1.9",
68703
+ version: "3.2.0",
68704
68704
  mcpName: "io.github.chrischall/infinitecampus-mcp",
68705
68705
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district, read-only (plus local document download)",
68706
68706
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -69427,6 +69427,10 @@ var ICClient = class {
69427
69427
  // linkedDistrictName → primaryDistrictName
69428
69428
  primaryName;
69429
69429
  featuresCache = /* @__PURE__ */ new Map();
69430
+ /** Campus version per district, read once. A failed read is dropped, to be retried after VERSION_RETRY_MS. */
69431
+ versions = /* @__PURE__ */ new Map();
69432
+ /** When each district's last version read failed — see VERSION_RETRY_MS. */
69433
+ versionFailedAt = /* @__PURE__ */ new Map();
69430
69434
  /**
69431
69435
  * True when the primary account has empty creds (fetchproxy mode). The
69432
69436
  * client uses pre-loaded cookies in place of the `verify.jsp` POST and
@@ -69597,7 +69601,37 @@ var ICClient = class {
69597
69601
  async request(district, path, opts = {}) {
69598
69602
  const account2 = await this.resolveAccount(district);
69599
69603
  await this.managers.get(account2.name).ensure();
69600
- return this.doRequest(account2, path, opts);
69604
+ try {
69605
+ return await this.doRequest(account2, path, opts);
69606
+ } catch (e) {
69607
+ if (e instanceof PortalUnreachableError) e.setCampusVersion(await this.campusVersion(account2.name));
69608
+ throw e;
69609
+ }
69610
+ }
69611
+ /**
69612
+ * The district's Campus version (e.g. "2635.16"), or null when it cannot be
69613
+ * read. DIAGNOSTIC ONLY — nothing chooses behaviour by it. The only place IC
69614
+ * states it is the parent login page footer ("Version:Campus-2635.16"),
69615
+ * which needs no session, so this works in fetchproxy mode too and never
69616
+ * touches a district's cookies.
69617
+ */
69618
+ campusVersion(district) {
69619
+ const account2 = this.accounts.get(district);
69620
+ if (!account2) return Promise.resolve(null);
69621
+ const failedAt = this.versionFailedAt.get(account2.name);
69622
+ if (failedAt !== void 0 && Date.now() - failedAt < VERSION_RETRY_MS) return Promise.resolve(null);
69623
+ let pending = this.versions.get(account2.name);
69624
+ if (!pending) {
69625
+ pending = fetchCampusVersion(account2).then((v) => {
69626
+ if (v === null) {
69627
+ this.versions.delete(account2.name);
69628
+ this.versionFailedAt.set(account2.name, Date.now());
69629
+ }
69630
+ return v;
69631
+ });
69632
+ this.versions.set(account2.name, pending);
69633
+ }
69634
+ return pending;
69601
69635
  }
69602
69636
  /**
69603
69637
  * The account for `district`, running CUPS discovery once on a miss.
@@ -69973,7 +70007,30 @@ var PortalUnreachableError = class extends Error {
69973
70007
  }
69974
70008
  district;
69975
70009
  status;
70010
+ /** The district's Campus version, when it could be read — for bug reports. */
70011
+ campusVersion;
70012
+ setCampusVersion(version2) {
70013
+ if (version2 === null) return;
70014
+ this.campusVersion = version2;
70015
+ this.message += ` [Campus ${version2}]`;
70016
+ }
69976
70017
  };
70018
+ function parseCampusVersion(html) {
70019
+ return /Version:\s*Campus-([0-9][\w.-]*)/.exec(html)?.[1] ?? null;
70020
+ }
70021
+ async function fetchCampusVersion(account2) {
70022
+ try {
70023
+ const res = await icFetch(
70024
+ `${account2.baseUrl}/campus/portal/parents/${encodeURIComponent(account2.district)}.jsp`,
70025
+ {},
70026
+ VERSION_TIMEOUT_MS
70027
+ );
70028
+ if (!res.ok) return null;
70029
+ return parseCampusVersion(await res.text());
70030
+ } catch {
70031
+ return null;
70032
+ }
70033
+ }
69977
70034
  var SessionExpiredError = class extends Error {
69978
70035
  constructor(district) {
69979
70036
  super(`Session expired for district '${district}' after re-login retry`);
@@ -69995,6 +70052,8 @@ var DocumentOriginNotAllowedError = class extends Error {
69995
70052
  allowedOrigin;
69996
70053
  };
69997
70054
  var REQUEST_TIMEOUT_MS = 3e4;
70055
+ var VERSION_TIMEOUT_MS = 5e3;
70056
+ var VERSION_RETRY_MS = 5 * 60 * 1e3;
69998
70057
  var DOWNLOAD_TIMEOUT_MS = 12e4;
69999
70058
  var MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
70000
70059
  function icFetch(url4, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
@@ -70119,12 +70178,19 @@ function registerHealthcheckTools(server, state) {
70119
70178
  const detail = {
70120
70179
  base_url: state.account.baseUrl,
70121
70180
  configured_district: state.account.district,
70122
- auth_source: state.source ?? "unknown"
70181
+ auth_source: state.source ?? "unknown",
70182
+ // Read off the login page, so it needs no session and is reported
70183
+ // even when discovery cannot run. Diagnostic only (#253).
70184
+ campus_version: await state.client.campusVersion(state.account.name)
70123
70185
  };
70124
70186
  try {
70125
70187
  await state.client.ensureDiscovery();
70126
70188
  const districts = state.client.listDistricts();
70127
- detail.districts = districts.map((d) => ({ name: d.name, linked: d.linked }));
70189
+ detail.districts = await Promise.all(districts.map(async (d) => ({
70190
+ name: d.name,
70191
+ linked: d.linked,
70192
+ campus_version: await state.client.campusVersion(d.name)
70193
+ })));
70128
70194
  const linked = districts.filter((d) => d.linked).map((d) => d.name);
70129
70195
  detail.configured_district_is_linked = districts.some(
70130
70196
  (d) => d.name === state.account.district && d.linked
@@ -70637,6 +70703,61 @@ function registerDocumentTools(server, client) {
70637
70703
  }
70638
70704
 
70639
70705
  // src/tools/calendar.ts
70706
+ function isRouteFailure(e) {
70707
+ return e instanceof PortalUnreachableError || is404(e);
70708
+ }
70709
+ var preferDayRoute = /* @__PURE__ */ new WeakMap();
70710
+ async function fetchDayRoute(client, district, calendarID, route) {
70711
+ if (route === "instructionalDay") {
70712
+ return toArray(await client.request(
70713
+ district,
70714
+ `/campus/resources/calendar/instructionalDay?calendarID=${calendarID}`
70715
+ ));
70716
+ }
70717
+ const all = toArray(await client.request(
70718
+ district,
70719
+ `/campus/resources/calendar/day?calendarID=${calendarID}`
70720
+ ));
70721
+ return all.filter((d) => d.isInstructionalDay);
70722
+ }
70723
+ async function fetchInstructionalDays(client, district, calendarID) {
70724
+ let preferred = preferDayRoute.get(client);
70725
+ if (!preferred) preferDayRoute.set(client, preferred = /* @__PURE__ */ new Set());
70726
+ const routes = preferred.has(district) ? ["day", "instructionalDay"] : ["instructionalDay", "day"];
70727
+ for (const route of routes) {
70728
+ try {
70729
+ const days = await fetchDayRoute(client, district, calendarID, route);
70730
+ if (route === "day") preferred.add(district);
70731
+ else preferred.delete(district);
70732
+ return days;
70733
+ } catch (e) {
70734
+ if (!isRouteFailure(e)) throw e;
70735
+ }
70736
+ }
70737
+ return null;
70738
+ }
70739
+ async function fetchDayEvents(client, district, personID) {
70740
+ let groups;
70741
+ try {
70742
+ groups = toArray(await client.request(
70743
+ district,
70744
+ `/campus/resources/portal/dayEvent/byEnrollment?personID=${encodeURIComponent(personID)}`
70745
+ ));
70746
+ } catch (e) {
70747
+ if (!isRouteFailure(e)) throw e;
70748
+ groups = [];
70749
+ }
70750
+ const byCalendar = /* @__PURE__ */ new Map();
70751
+ for (const ev of groups.flatMap((g) => toArray(g.events))) {
70752
+ let byDate = byCalendar.get(ev.calendarID);
70753
+ if (!byDate) byCalendar.set(ev.calendarID, byDate = /* @__PURE__ */ new Map());
70754
+ byDate.set(ev.date, [...byDate.get(ev.date) ?? [], ev]);
70755
+ }
70756
+ return byCalendar;
70757
+ }
70758
+ function joinLabels(labels) {
70759
+ return [...new Set(labels.filter((l) => !!l))].join("; ");
70760
+ }
70640
70761
  var argsSchema8 = external_exports.object({
70641
70762
  district: external_exports.string(),
70642
70763
  studentId: external_exports.string().describe("Student personID from ic_list_students"),
@@ -70645,7 +70766,7 @@ var argsSchema8 = external_exports.object({
70645
70766
  });
70646
70767
  function registerCalendarTools(server, client) {
70647
70768
  server.registerTool("ic_list_school_days", {
70648
- description: "List a student's school days (instructional calendar) grouped by term. Returns one entry per enrollment, with term boundaries (Q1-Q4 start/end dates) and the school days inside each term \u2014 including comments like 'Teacher Workday' or 'Spring Break'. Use since/until to narrow the range.",
70769
+ description: "List a student's school days (instructional calendar) grouped by term. Returns one entry per enrollment, with term boundaries (Q1-Q4 start/end dates) and the school days inside each term \u2014 a day carries comments when the calendar labels it (e.g. 'Early Release Day'). Each enrollment also lists its labelled daysOff (e.g. 'Holiday', 'Teacher Work Day', 'Spring Break'), including breaks that fall between terms. Use since/until to narrow the range. An enrollment whose day list the district won't serve comes back with warning 'SchoolDaysUnavailable' (plus campusVersion when known), its terms and days off, and no school days.",
70649
70770
  annotations: { readOnlyHint: true, openWorldHint: true },
70650
70771
  inputSchema: external_exports.object({ ...argsSchema8.shape, view: viewArg() })
70651
70772
  }, async (rawArgs) => {
@@ -70653,18 +70774,18 @@ function registerCalendarTools(server, client) {
70653
70774
  const student = await findStudent(client, args.district, args.studentId);
70654
70775
  if (!student) return studentNotFound(args.studentId);
70655
70776
  const result = [];
70656
- for (const enr of student.enrollments ?? []) {
70657
- const [termsRaw, daysRaw] = await Promise.all([
70777
+ const enrollments = student.enrollments ?? [];
70778
+ const events = enrollments.length > 0 ? await fetchDayEvents(client, args.district, args.studentId) : /* @__PURE__ */ new Map();
70779
+ for (const enr of enrollments) {
70780
+ const [termsRaw, days] = await Promise.all([
70658
70781
  client.request(args.district, `/campus/resources/term?structureID=${enr.structureID}`),
70659
- client.request(args.district, `/campus/resources/calendar/instructionalDay?calendarID=${enr.calendarID}`)
70782
+ fetchInstructionalDays(client, args.district, enr.calendarID)
70660
70783
  ]);
70661
70784
  const terms = toArray(termsRaw);
70662
- const days = toArray(daysRaw);
70663
- const filteredDays = days.filter((d) => {
70664
- if (args.since && d.date < args.since) return false;
70665
- if (args.until && d.date > args.until) return false;
70666
- return true;
70667
- });
70785
+ const inRange2 = (date5) => !(args.since && date5 < args.since) && !(args.until && date5 > args.until);
70786
+ const filteredDays = (days ?? []).filter((d) => inRange2(d.date));
70787
+ const calendarEvents = events.get(enr.calendarID) ?? /* @__PURE__ */ new Map();
70788
+ const daysOff = [...calendarEvents.entries()].filter(([date5, evs]) => inRange2(date5) && evs.some((e) => e.instruction === false)).map(([date5, evs]) => ({ date: date5, comments: joinLabels(evs.map((e) => e.name)) })).sort((a, b) => a.date.localeCompare(b.date));
70668
70789
  const enrollmentTerms = terms.filter((t) => t.structureID === enr.structureID).sort((a, b) => a.seq - b.seq);
70669
70790
  const trimmedTerms = enrollmentTerms.map((t) => ({
70670
70791
  termID: t.termID,
@@ -70673,16 +70794,21 @@ function registerCalendarTools(server, client) {
70673
70794
  endDate: t.endDate,
70674
70795
  days: filteredDays.filter((d) => d.date >= t.startDate && d.date <= t.endDate).map((d) => {
70675
70796
  const out = { date: d.date, requiresAttendance: d.requiresAttendance };
70676
- if (d.comments) out.comments = d.comments;
70797
+ const comments = joinLabels([d.comments, ...(calendarEvents.get(d.date) ?? []).map((e) => e.name)]);
70798
+ if (comments) out.comments = comments;
70677
70799
  return out;
70678
70800
  })
70679
- })).filter((t) => t.days.length > 0 || !args.since && !args.until);
70801
+ })).filter((t) => days === null ? !(args.since && t.endDate < args.since) && !(args.until && t.startDate > args.until) : t.days.length > 0 || !args.since && !args.until);
70802
+ const campusVersion = days === null ? await client.campusVersion(args.district) : null;
70680
70803
  result.push({
70681
70804
  enrollmentID: enr.enrollmentID,
70682
70805
  calendarID: enr.calendarID,
70683
70806
  structureID: enr.structureID,
70684
70807
  calendarName: enr.calendarName,
70685
- terms: trimmedTerms
70808
+ ...days === null ? { warning: "SchoolDaysUnavailable" } : {},
70809
+ ...campusVersion ? { campusVersion } : {},
70810
+ terms: trimmedTerms,
70811
+ ...daysOff.length > 0 ? { daysOff } : {}
70686
70812
  });
70687
70813
  }
70688
70814
  return viewResponse(rawArgs.view, result);
@@ -71034,7 +71160,7 @@ try {
71034
71160
  }
71035
71161
  var COMMON = {
71036
71162
  name: "infinitecampus",
71037
- version: "3.1.9"
71163
+ version: "3.2.0"
71038
71164
  // x-release-please-version
71039
71165
  };
71040
71166
  var healthState = { account, source, configError, client: null };
package/dist/client.js CHANGED
@@ -32,6 +32,10 @@ export class ICClient {
32
32
  linkedTo = new Map(); // linkedDistrictName → primaryDistrictName
33
33
  primaryName;
34
34
  featuresCache = new Map();
35
+ /** Campus version per district, read once. A failed read is dropped, to be retried after VERSION_RETRY_MS. */
36
+ versions = new Map();
37
+ /** When each district's last version read failed — see VERSION_RETRY_MS. */
38
+ versionFailedAt = new Map();
35
39
  /**
36
40
  * True when the primary account has empty creds (fetchproxy mode). The
37
41
  * client uses pre-loaded cookies in place of the `verify.jsp` POST and
@@ -248,7 +252,43 @@ export class ICClient {
248
252
  async request(district, path, opts = {}) {
249
253
  const account = await this.resolveAccount(district);
250
254
  await this.managers.get(account.name).ensure();
251
- return this.doRequest(account, path, opts);
255
+ try {
256
+ return await this.doRequest(account, path, opts);
257
+ }
258
+ catch (e) {
259
+ // A 5xx is the failure most likely to be a Campus release changing
260
+ // behaviour under us (#253), so it carries the version into the report.
261
+ if (e instanceof PortalUnreachableError)
262
+ e.setCampusVersion(await this.campusVersion(account.name));
263
+ throw e;
264
+ }
265
+ }
266
+ /**
267
+ * The district's Campus version (e.g. "2635.16"), or null when it cannot be
268
+ * read. DIAGNOSTIC ONLY — nothing chooses behaviour by it. The only place IC
269
+ * states it is the parent login page footer ("Version:Campus-2635.16"),
270
+ * which needs no session, so this works in fetchproxy mode too and never
271
+ * touches a district's cookies.
272
+ */
273
+ campusVersion(district) {
274
+ const account = this.accounts.get(district);
275
+ if (!account)
276
+ return Promise.resolve(null);
277
+ const failedAt = this.versionFailedAt.get(account.name);
278
+ if (failedAt !== undefined && Date.now() - failedAt < VERSION_RETRY_MS)
279
+ return Promise.resolve(null);
280
+ let pending = this.versions.get(account.name);
281
+ if (!pending) {
282
+ pending = fetchCampusVersion(account).then((v) => {
283
+ if (v === null) {
284
+ this.versions.delete(account.name);
285
+ this.versionFailedAt.set(account.name, Date.now());
286
+ }
287
+ return v;
288
+ });
289
+ this.versions.set(account.name, pending);
290
+ }
291
+ return pending;
252
292
  }
253
293
  /**
254
294
  * The account for `district`, running CUPS discovery once on a miss.
@@ -792,12 +832,35 @@ export class AuthFailedError extends Error {
792
832
  export class PortalUnreachableError extends Error {
793
833
  district;
794
834
  status;
835
+ /** The district's Campus version, when it could be read — for bug reports. */
836
+ campusVersion;
795
837
  constructor(district, status) {
796
838
  super(`Portal unreachable for district '${district}' (status ${status})`);
797
839
  this.district = district;
798
840
  this.status = status;
799
841
  this.name = 'PortalUnreachableError';
800
842
  }
843
+ setCampusVersion(version) {
844
+ if (version === null)
845
+ return;
846
+ this.campusVersion = version;
847
+ this.message += ` [Campus ${version}]`;
848
+ }
849
+ }
850
+ /** The version from a Campus login page footer ("Version:Campus-2635.16"), or null. */
851
+ export function parseCampusVersion(html) {
852
+ return /Version:\s*Campus-([0-9][\w.-]*)/.exec(html)?.[1] ?? null;
853
+ }
854
+ async function fetchCampusVersion(account) {
855
+ try {
856
+ const res = await icFetch(`${account.baseUrl}/campus/portal/parents/${encodeURIComponent(account.district)}.jsp`, {}, VERSION_TIMEOUT_MS);
857
+ if (!res.ok)
858
+ return null;
859
+ return parseCampusVersion(await res.text());
860
+ }
861
+ catch {
862
+ return null;
863
+ }
801
864
  }
802
865
  export class SessionExpiredError extends Error {
803
866
  district;
@@ -821,6 +884,13 @@ export class DocumentOriginNotAllowedError extends Error {
821
884
  }
822
885
  /** Per-request ceiling for every IC call except a document download. */
823
886
  export const REQUEST_TIMEOUT_MS = 30_000;
887
+ /**
888
+ * The Campus version read rides on failures (request() reads it on every 5xx),
889
+ * so it must not add the full request timeout to a call that already failed.
890
+ */
891
+ export const VERSION_TIMEOUT_MS = 5_000;
892
+ /** How long a failed version read is remembered before the page is read again. */
893
+ export const VERSION_RETRY_MS = 5 * 60 * 1000;
824
894
  /** Ceiling for a document download, which moves a whole file. */
825
895
  export const DOWNLOAD_TIMEOUT_MS = 120_000;
826
896
  /**
package/dist/index.js CHANGED
@@ -56,7 +56,7 @@ catch (e) {
56
56
  // avoids — see `unconfiguredClient`.
57
57
  const COMMON = {
58
58
  name: 'infinitecampus',
59
- version: '3.1.9', // x-release-please-version
59
+ version: '3.2.0', // x-release-please-version
60
60
  };
61
61
  // Shared with the healthcheck so it can report the SAME state the tools see,
62
62
  // including on the unconfigured path where there are no tools at all.
@@ -1,6 +1,84 @@
1
1
  import { viewArg, viewResponse } from '../view.js';
2
2
  import { z } from 'zod';
3
- import { findStudent, studentNotFound, toArray } from './_shared.js';
3
+ import { PortalUnreachableError } from '../client.js';
4
+ import { findStudent, is404, studentNotFound, toArray } from './_shared.js';
5
+ /** The portal answered, but not with this resource: worth trying another route. */
6
+ function isRouteFailure(e) {
7
+ return e instanceof PortalUnreachableError || is404(e);
8
+ }
9
+ /**
10
+ * Districts (per client) that last answered on calendar/day rather than
11
+ * instructionalDay. Asking the broken route first on every call would cost one
12
+ * guaranteed-failing request per enrollment. It is a preference, not a
13
+ * verdict: if calendar/day stops answering, instructionalDay is tried again.
14
+ */
15
+ const preferDayRoute = new WeakMap();
16
+ async function fetchDayRoute(client, district, calendarID, route) {
17
+ if (route === 'instructionalDay') {
18
+ return toArray(await client.request(district, `/campus/resources/calendar/instructionalDay?calendarID=${calendarID}`));
19
+ }
20
+ const all = toArray(await client.request(district, `/campus/resources/calendar/day?calendarID=${calendarID}`));
21
+ return all.filter((d) => d.isInstructionalDay);
22
+ }
23
+ /**
24
+ * The calendar's instructional days, or null when no route to them answers.
25
+ *
26
+ * Some districts now 500 on calendar/instructionalDay: Campus matches it to
27
+ * calendar/{calendarID} and reads "instructionalDay" as the ID (#253).
28
+ * calendar/day returns the same days plus the non-instructional ones, flagged
29
+ * by isInstructionalDay. Session and auth errors are not route failures and
30
+ * propagate, so a dead session never reads as "days unknown".
31
+ */
32
+ async function fetchInstructionalDays(client, district, calendarID) {
33
+ let preferred = preferDayRoute.get(client);
34
+ if (!preferred)
35
+ preferDayRoute.set(client, (preferred = new Set()));
36
+ const routes = preferred.has(district) ? ['day', 'instructionalDay'] : ['instructionalDay', 'day'];
37
+ for (const route of routes) {
38
+ try {
39
+ const days = await fetchDayRoute(client, district, calendarID, route);
40
+ if (route === 'day')
41
+ preferred.add(district);
42
+ else
43
+ preferred.delete(district);
44
+ return days;
45
+ }
46
+ catch (e) {
47
+ if (!isRouteFailure(e))
48
+ throw e;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Event labels by calendarID → date, from portal/dayEvent/byEnrollment. Neither
55
+ * day route carries `comments` any more, so this is where "Holiday" or
56
+ * "Teacher Work Day" lives now. Labels are a nicety: a route failure costs only
57
+ * the labels (an empty map), but a session error still propagates.
58
+ */
59
+ async function fetchDayEvents(client, district, personID) {
60
+ let groups;
61
+ try {
62
+ groups = toArray(await client.request(district, `/campus/resources/portal/dayEvent/byEnrollment?personID=${encodeURIComponent(personID)}`));
63
+ }
64
+ catch (e) {
65
+ if (!isRouteFailure(e))
66
+ throw e;
67
+ groups = [];
68
+ }
69
+ const byCalendar = new Map();
70
+ for (const ev of groups.flatMap((g) => toArray(g.events))) {
71
+ let byDate = byCalendar.get(ev.calendarID);
72
+ if (!byDate)
73
+ byCalendar.set(ev.calendarID, (byDate = new Map()));
74
+ byDate.set(ev.date, [...(byDate.get(ev.date) ?? []), ev]);
75
+ }
76
+ return byCalendar;
77
+ }
78
+ /** Distinct, non-empty labels joined the way IC's old `comments` read. */
79
+ function joinLabels(labels) {
80
+ return [...new Set(labels.filter((l) => !!l))].join('; ');
81
+ }
4
82
  const argsSchema = z.object({
5
83
  district: z.string(),
6
84
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -9,7 +87,7 @@ const argsSchema = z.object({
9
87
  });
10
88
  export function registerCalendarTools(server, client) {
11
89
  server.registerTool('ic_list_school_days', {
12
- description: "List a student's school days (instructional calendar) grouped by term. Returns one entry per enrollment, with term boundaries (Q1-Q4 start/end dates) and the school days inside each term — including comments like 'Teacher Workday' or 'Spring Break'. Use since/until to narrow the range.",
90
+ description: "List a student's school days (instructional calendar) grouped by term. Returns one entry per enrollment, with term boundaries (Q1-Q4 start/end dates) and the school days inside each term — a day carries comments when the calendar labels it (e.g. 'Early Release Day'). Each enrollment also lists its labelled daysOff (e.g. 'Holiday', 'Teacher Work Day', 'Spring Break'), including breaks that fall between terms. Use since/until to narrow the range. An enrollment whose day list the district won't serve comes back with warning 'SchoolDaysUnavailable' (plus campusVersion when known), its terms and days off, and no school days.",
13
91
  annotations: { readOnlyHint: true, openWorldHint: true },
14
92
  inputSchema: z.object({ ...argsSchema.shape, view: viewArg() }),
15
93
  }, async (rawArgs) => {
@@ -19,22 +97,27 @@ export function registerCalendarTools(server, client) {
19
97
  if (!student)
20
98
  return studentNotFound(args.studentId);
21
99
  const result = [];
100
+ const enrollments = student.enrollments ?? [];
101
+ // One call covers every enrollment; skip it when there are none.
102
+ const events = enrollments.length > 0
103
+ ? await fetchDayEvents(client, args.district, args.studentId)
104
+ : new Map();
22
105
  // 2. For each enrollment, fetch terms + instructional days
23
- for (const enr of student.enrollments ?? []) {
24
- const [termsRaw, daysRaw] = await Promise.all([
106
+ for (const enr of enrollments) {
107
+ const [termsRaw, days] = await Promise.all([
25
108
  client.request(args.district, `/campus/resources/term?structureID=${enr.structureID}`),
26
- client.request(args.district, `/campus/resources/calendar/instructionalDay?calendarID=${enr.calendarID}`),
109
+ fetchInstructionalDays(client, args.district, enr.calendarID),
27
110
  ]);
28
111
  const terms = toArray(termsRaw);
29
- const days = toArray(daysRaw);
30
112
  // 3. Filter days by since/until range
31
- const filteredDays = days.filter((d) => {
32
- if (args.since && d.date < args.since)
33
- return false;
34
- if (args.until && d.date > args.until)
35
- return false;
36
- return true;
37
- });
113
+ const inRange = (date) => !(args.since && date < args.since) && !(args.until && date > args.until);
114
+ const filteredDays = (days ?? []).filter((d) => inRange(d.date));
115
+ const calendarEvents = events.get(enr.calendarID) ?? new Map();
116
+ // Labelled dates IC itself marks as out of instruction, oldest first.
117
+ const daysOff = [...calendarEvents.entries()]
118
+ .filter(([date, evs]) => inRange(date) && evs.some((e) => e.instruction === false))
119
+ .map(([date, evs]) => ({ date, comments: joinLabels(evs.map((e) => e.name)) }))
120
+ .sort((a, b) => a.date.localeCompare(b.date));
38
121
  // 4. Group days into terms (only terms in this structureID; sorted by seq)
39
122
  const enrollmentTerms = terms
40
123
  .filter((t) => t.structureID === enr.structureID)
@@ -49,19 +132,27 @@ export function registerCalendarTools(server, client) {
49
132
  .filter((d) => d.date >= t.startDate && d.date <= t.endDate)
50
133
  .map((d) => {
51
134
  const out = { date: d.date, requiresAttendance: d.requiresAttendance };
52
- if (d.comments)
53
- out.comments = d.comments;
135
+ const comments = joinLabels([d.comments, ...(calendarEvents.get(d.date) ?? []).map((e) => e.name)]);
136
+ if (comments)
137
+ out.comments = comments;
54
138
  return out;
55
139
  }),
56
140
  }))
57
- // Drop empty terms (term entirely outside the since/until range)
58
- .filter((t) => t.days.length > 0 || (!args.since && !args.until));
141
+ // Drop terms entirely outside the since/until range. With no days to
142
+ // go on, judge a term by whether its own dates overlap the range.
143
+ .filter((t) => days === null
144
+ ? !(args.since && t.endDate < args.since) && !(args.until && t.startDate > args.until)
145
+ : t.days.length > 0 || (!args.since && !args.until));
146
+ const campusVersion = days === null ? await client.campusVersion(args.district) : null;
59
147
  result.push({
60
148
  enrollmentID: enr.enrollmentID,
61
149
  calendarID: enr.calendarID,
62
150
  structureID: enr.structureID,
63
151
  calendarName: enr.calendarName,
152
+ ...(days === null ? { warning: 'SchoolDaysUnavailable' } : {}),
153
+ ...(campusVersion ? { campusVersion } : {}),
64
154
  terms: trimmedTerms,
155
+ ...(daysOff.length > 0 ? { daysOff } : {}),
65
156
  });
66
157
  }
67
158
  return viewResponse(rawArgs.view, result);
@@ -28,6 +28,9 @@ export function registerHealthcheckTools(server, state) {
28
28
  base_url: state.account.baseUrl,
29
29
  configured_district: state.account.district,
30
30
  auth_source: state.source ?? 'unknown',
31
+ // Read off the login page, so it needs no session and is reported
32
+ // even when discovery cannot run. Diagnostic only (#253).
33
+ campus_version: await state.client.campusVersion(state.account.name),
31
34
  };
32
35
  try {
33
36
  // Discovery is what knows about LINKED districts, and it needs a live
@@ -35,7 +38,11 @@ export function registerHealthcheckTools(server, state) {
35
38
  // is known and let the probe below decide.
36
39
  await state.client.ensureDiscovery();
37
40
  const districts = state.client.listDistricts();
38
- detail.districts = districts.map((d) => ({ name: d.name, linked: d.linked }));
41
+ detail.districts = await Promise.all(districts.map(async (d) => ({
42
+ name: d.name,
43
+ linked: d.linked,
44
+ campus_version: await state.client.campusVersion(d.name),
45
+ })));
39
46
  const linked = districts.filter((d) => d.linked).map((d) => d.name);
40
47
  detail.configured_district_is_linked = districts.some((d) => d.name === state.account.district && d.linked);
41
48
  if (linked.length > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "3.1.9",
3
+ "version": "3.2.0",
4
4
  "mcpName": "io.github.chrischall/infinitecampus-mcp",
5
5
  "description": "Infinite Campus (Campus Parent) MCP server — multi-district, read-only (plus local document download)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/infinitecampus-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.1.9",
9
+ "version": "3.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "3.1.9",
14
+ "version": "3.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -96,7 +96,8 @@ registered even when the server is unconfigured (every other tool is not), and
96
96
  it reports whether the configured `IC_DISTRICT` is one the account is actually
97
97
  LINKED to. A district you are not linked to fails every other tool for a reason
98
98
  that looks nothing like "wrong district", so run this first when things fail
99
- inexplicably. Most student-scoped tools also take `studentId` (the personID from `ic_list_students`).
99
+ inexplicably. It also reports each district's Campus version (`campus_version`,
100
+ e.g. `2635.16`) — quote it in any bug report about an endpoint that changed. Most student-scoped tools also take `studentId` (the personID from `ic_list_students`).
100
101
 
101
102
  ## Tools
102
103
 
@@ -119,7 +120,7 @@ inexplicably. Most student-scoped tools also take `studentId` (the personID from
119
120
  ### Daily life
120
121
  | Tool | Notes |
121
122
  |------|-------|
122
- | `ic_list_school_days(district, studentId, view?)` | Instructional calendar with term boundaries. |
123
+ | `ic_list_school_days(district, studentId, since?, until?, view?)` | Instructional calendar with term boundaries; labelled days carry `comments`, and each enrollment lists its `daysOff` (holidays, workdays, breaks — including ones between terms). `warning: 'SchoolDaysUnavailable'` means the district wouldn't serve the day list; terms and `daysOff` still come back. |
123
124
  | `ic_list_attendance(district, studentId, since?, until?, view?)` | Per-course attendance summary grouped by term. |
124
125
  | `ic_list_attendance_events(district, studentId, since?, until?, excusedOnly?, view?)` | Individual absence/tardy events with codes and human comments. |
125
126
  | `ic_list_behavior(district, studentId, since?, until?, view?)` | Returns a `FeatureDisabled` warning if the district has no behavior module enabled — this is not an error. |
@@ -97,6 +97,12 @@ Two calls per enrollment, then join term windows to days client-side:
97
97
  ```sh
98
98
  ic_get "/campus/resources/term?structureID=$STRUCTURE_ID" | jq . # term boundaries
99
99
  ic_get "/campus/resources/calendar/instructionalDay?calendarID=$CALENDAR_ID" | jq . # day list
100
+ # some districts 500 on instructionalDay (#253) — same days via calendar/day:
101
+ ic_get "/campus/resources/calendar/day?calendarID=$CALENDAR_ID" | jq '[.[] | select(.isInstructionalDay)]'
102
+ # day labels (holidays, workdays, early release) — the day lists no longer carry comments:
103
+ ic_get "/campus/resources/portal/dayEvent/byEnrollment?personID=$PID" | jq '[.[].events[] | {date, name, instruction}]'
104
+ # Campus version, no session needed (footer of the parent login page):
105
+ curl -s "$IC_BASE_URL/campus/portal/parents/$IC_DISTRICT.jsp" | grep -o 'Version:Campus-[0-9.]*'
100
106
 
101
107
  # days within term N's [startDate,endDate]:
102
108
  jq --slurpfile terms terms.json --arg t "$TERM_ID" \