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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +145 -19
- package/dist/client.js +71 -1
- package/dist/index.js +1 -1
- package/dist/tools/calendar.js +108 -17
- package/dist/tools/healthcheck.js +8 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ic/SKILL.md +3 -2
- package/skills/infinitecampus-api/references/endpoints.md +6 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) => ({
|
|
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
|
|
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
|
-
|
|
70657
|
-
|
|
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
|
|
70782
|
+
fetchInstructionalDays(client, args.district, enr.calendarID)
|
|
70660
70783
|
]);
|
|
70661
70784
|
const terms = toArray(termsRaw);
|
|
70662
|
-
const
|
|
70663
|
-
const filteredDays = days.filter((d) =>
|
|
70664
|
-
|
|
70665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
import { viewArg, viewResponse } from '../view.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
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 —
|
|
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
|
|
24
|
-
const [termsRaw,
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
58
|
-
|
|
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) => ({
|
|
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.
|
|
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.
|
|
9
|
+
"version": "3.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "3.
|
|
14
|
+
"version": "3.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ic/SKILL.md
CHANGED
|
@@ -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.
|
|
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" \
|