infinitecampus-mcp 2.3.4 → 2.3.6
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 +609 -205
- package/dist/client.js +161 -114
- package/dist/index.js +1 -1
- package/dist/tools/_shared.js +2 -11
- package/dist/tools/assessments.js +3 -2
- package/dist/tools/assignments.js +2 -2
- package/dist/tools/attendance.js +3 -2
- package/dist/tools/attendance_events.js +3 -2
- package/dist/tools/behavior.js +3 -2
- package/dist/tools/calendar.js +3 -2
- package/dist/tools/districts.js +2 -2
- package/dist/tools/documents.js +5 -4
- package/dist/tools/features.js +3 -2
- package/dist/tools/fees.js +3 -2
- package/dist/tools/foodservice.js +3 -2
- package/dist/tools/grades.js +2 -2
- package/dist/tools/messages.js +4 -3
- package/dist/tools/recent_grades.js +2 -2
- package/dist/tools/schedule.js +2 -2
- package/dist/tools/students.js +2 -2
- package/dist/tools/teachers.js +3 -2
- package/package.json +4 -4
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { writeFile, stat } from 'fs/promises';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
3
|
import { parseCookieJar } from '@chrischall/mcp-utils';
|
|
4
|
+
import { createCookieSessionManager } from '@chrischall/mcp-utils/session';
|
|
4
5
|
const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
|
|
5
6
|
export class ICClient {
|
|
6
7
|
accounts = new Map();
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* One CookieSessionManager per district (primary + each discovered linked
|
|
10
|
+
* district). The manager owns the session lifecycle: single-flight login,
|
|
11
|
+
* `withSession` exactly-one 401 replay, `invalidate()`, permanent-error
|
|
12
|
+
* caching, proactive `maxAgeMs` (SESSION_TTL_MS) expiry, and — for linked
|
|
13
|
+
* districts — the CUPS-minted session installed via `seed()` during
|
|
14
|
+
* discovery.
|
|
15
|
+
*/
|
|
16
|
+
managers = new Map();
|
|
8
17
|
linkedTo = new Map(); // linkedDistrictName → primaryDistrictName
|
|
9
18
|
primaryName;
|
|
10
19
|
featuresCache = new Map();
|
|
@@ -17,6 +26,8 @@ export class ICClient {
|
|
|
17
26
|
*/
|
|
18
27
|
fetchproxyMode = false;
|
|
19
28
|
fetchproxyDiscoveryRan = false;
|
|
29
|
+
/** fetchproxy cookies, consumed by the primary manager's first login. */
|
|
30
|
+
preloaded = null;
|
|
20
31
|
/**
|
|
21
32
|
* `preloaded` is the fetchproxy escape hatch: when set, the client treats
|
|
22
33
|
* the supplied cookies as a freshly-completed login on the primary account.
|
|
@@ -29,27 +40,73 @@ export class ICClient {
|
|
|
29
40
|
constructor(account, opts = {}) {
|
|
30
41
|
this.accounts.set(account.name, account);
|
|
31
42
|
this.primaryName = account.name;
|
|
43
|
+
this.managers.set(account.name, this.makeManager(account));
|
|
32
44
|
if (opts.preloaded) {
|
|
33
|
-
this.
|
|
34
|
-
cookie: opts.preloaded.cookieHeader,
|
|
35
|
-
xsrfToken: opts.preloaded.xsrfToken,
|
|
36
|
-
loggedInAt: Date.now(),
|
|
37
|
-
loginInFlight: null,
|
|
38
|
-
});
|
|
45
|
+
this.preloaded = opts.preloaded;
|
|
39
46
|
this.fetchproxyMode = true;
|
|
40
47
|
}
|
|
41
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the per-district CookieSessionManager.
|
|
51
|
+
*
|
|
52
|
+
* - `maxAgeMs: SESSION_TTL_MS` makes `ensure()` proactively invalidate and
|
|
53
|
+
* re-login a session older than 5h (single-flight), replacing IC's old
|
|
54
|
+
* hand-rolled `ensureFresh` wrapper. `now` reads `Date.now` at call time so
|
|
55
|
+
* the clock stays mockable in tests.
|
|
56
|
+
* - `isPermanentError` caches the one config error the process can never
|
|
57
|
+
* recover from (fetchproxy no-creds) so `ensure()` fails fast instead of
|
|
58
|
+
* pointlessly retrying a login that can never succeed.
|
|
59
|
+
* - `onReplayLoginError` rethrows the re-login failure from `withSession`'s
|
|
60
|
+
* expiry-replay path. By default that failure is swallowed and the stale
|
|
61
|
+
* 401 returned; rethrowing surfaces the actionable error (e.g. fetchproxy's
|
|
62
|
+
* "sign back into your IC portal in the browser") to the caller instead of
|
|
63
|
+
* a generic SessionExpiredError — the behavior IC's `lastLoginError`
|
|
64
|
+
* bookkeeping used to provide.
|
|
65
|
+
*/
|
|
66
|
+
makeManager(account) {
|
|
67
|
+
return createCookieSessionManager({
|
|
68
|
+
login: () => this.login(account),
|
|
69
|
+
isExpired: (res) => this.detectExpiredSession(account.name, res),
|
|
70
|
+
isPermanentError: (e) => e instanceof AuthFailedError && e.permanent,
|
|
71
|
+
maxAgeMs: SESSION_TTL_MS,
|
|
72
|
+
now: () => Date.now(),
|
|
73
|
+
onReplayLoginError: (err) => { throw err; },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `isExpired` predicate for `withSession`: a 401 means the session died.
|
|
78
|
+
* Side effect for linked districts: the shared CUPS SSO umbrella died with
|
|
79
|
+
* it, so drop the primary and every *sibling* linked session too — the
|
|
80
|
+
* linked district's re-login (via `login()`'s linked branch) then re-logs
|
|
81
|
+
* the primary in, whose discovery re-establishes all of them. `withSession`
|
|
82
|
+
* invalidates the requesting district itself. Mirrors the old hand-rolled
|
|
83
|
+
* doRequest 401 handling.
|
|
84
|
+
*/
|
|
85
|
+
detectExpiredSession(district, res) {
|
|
86
|
+
if (res.status !== 401)
|
|
87
|
+
return false;
|
|
88
|
+
const primaryName = this.linkedTo.get(district);
|
|
89
|
+
if (primaryName) {
|
|
90
|
+
this.managers.get(primaryName).invalidate();
|
|
91
|
+
for (const linked of this.linkedTo.keys()) {
|
|
92
|
+
if (linked !== district)
|
|
93
|
+
this.managers.get(linked).invalidate();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
42
98
|
async ensureDiscovery() {
|
|
43
99
|
// Ensure primary account is logged in, which triggers CUPS linked-district discovery
|
|
44
|
-
await this.
|
|
45
|
-
// fetchproxy mode skips login
|
|
46
|
-
// it. Run discovery directly the first time someone asks for
|
|
47
|
-
// primary is never in linkedTo (it's the root of the linkedTo
|
|
48
|
-
// login()
|
|
49
|
-
// it re-auths through the primary instead of running discovery
|
|
100
|
+
const session = await this.managers.get(this.primaryName).ensure();
|
|
101
|
+
// fetchproxy mode skips the verify.jsp login and therefore the discovery
|
|
102
|
+
// call inside it. Run discovery directly the first time someone asks for
|
|
103
|
+
// it. The primary is never in linkedTo (it's the root of the linkedTo
|
|
104
|
+
// map). When login() runs for an already-linked district during a TTL
|
|
105
|
+
// refresh, it re-auths through the primary instead of running discovery
|
|
106
|
+
// itself.
|
|
50
107
|
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
|
|
51
108
|
this.fetchproxyDiscoveryRan = true;
|
|
52
|
-
await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
|
|
109
|
+
await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
|
|
53
110
|
}
|
|
54
111
|
}
|
|
55
112
|
listDistricts() {
|
|
@@ -86,66 +143,57 @@ export class ICClient {
|
|
|
86
143
|
if (!account)
|
|
87
144
|
throw new UnknownDistrictError(district, [...this.accounts.keys()]);
|
|
88
145
|
}
|
|
89
|
-
await this.
|
|
90
|
-
// In fetchproxy mode, login
|
|
91
|
-
// discoverLinkedDistricts. Run discovery once
|
|
92
|
-
// so linked districts are discoverable. (The
|
|
93
|
-
// linkedTo by construction.)
|
|
146
|
+
const session = await this.managers.get(account.name).ensure();
|
|
147
|
+
// In fetchproxy mode, the preloaded-cookie login never runs verify.jsp
|
|
148
|
+
// and therefore never calls discoverLinkedDistricts. Run discovery once
|
|
149
|
+
// on the first primary call so linked districts are discoverable. (The
|
|
150
|
+
// primary is never in linkedTo by construction.)
|
|
94
151
|
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account.name === this.primaryName) {
|
|
95
152
|
this.fetchproxyDiscoveryRan = true;
|
|
96
|
-
await this.discoverLinkedDistricts(account);
|
|
97
|
-
}
|
|
98
|
-
return this.doRequest(account, path, opts, false);
|
|
99
|
-
}
|
|
100
|
-
async ensureSession(account) {
|
|
101
|
-
let s = this.sessions.get(account.name);
|
|
102
|
-
if (s && Date.now() - s.loggedInAt < SESSION_TTL_MS)
|
|
103
|
-
return;
|
|
104
|
-
if (s?.loginInFlight) {
|
|
105
|
-
await s.loginInFlight;
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (!s) {
|
|
109
|
-
s = { cookie: '', xsrfToken: '', loggedInAt: 0, loginInFlight: null };
|
|
110
|
-
this.sessions.set(account.name, s);
|
|
111
|
-
}
|
|
112
|
-
s.loginInFlight = this.login(account);
|
|
113
|
-
try {
|
|
114
|
-
await s.loginInFlight;
|
|
115
|
-
}
|
|
116
|
-
finally {
|
|
117
|
-
s.loginInFlight = null;
|
|
153
|
+
await this.discoverLinkedDistricts(account, session);
|
|
118
154
|
}
|
|
155
|
+
return this.doRequest(account, path, opts);
|
|
119
156
|
}
|
|
120
157
|
async login(account) {
|
|
121
158
|
// Linked districts have no real credentials of their own — they hold
|
|
122
159
|
// synthetic '(linked)' placeholders and are authenticated by the primary's
|
|
123
|
-
// CUPS SSO switch, not by their own verify.jsp.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// a misleading password-error.
|
|
160
|
+
// CUPS SSO switch, not by their own verify.jsp. Whether the linked session
|
|
161
|
+
// expired by TTL or by 401 (detectExpiredSession dropped the primary in
|
|
162
|
+
// that case), re-auth through the primary: refresh it, which re-runs
|
|
163
|
+
// discoverLinkedDistricts and `seed()`s a fresh session into this
|
|
164
|
+
// district's manager — POSTing the placeholder creds to verify.jsp would
|
|
165
|
+
// instead yield a misleading password-error.
|
|
166
|
+
//
|
|
167
|
+
// This runs as the linked manager's `login`, which `ensure()` only invokes
|
|
168
|
+
// after invalidating any stale session — so `current` is `undefined` on
|
|
169
|
+
// entry and becomes non-undefined ONLY if the primary refresh re-ran
|
|
170
|
+
// discovery and seeded us. If the primary was still fresh (or discovery
|
|
171
|
+
// failed to restore this district), `current` stays undefined — surface a
|
|
172
|
+
// clear error rather than handing callers a dead session.
|
|
129
173
|
const primaryName = this.linkedTo.get(account.name);
|
|
130
174
|
if (primaryName) {
|
|
131
|
-
|
|
132
|
-
this.
|
|
133
|
-
|
|
134
|
-
await this.ensureSession(primaryAccount);
|
|
135
|
-
// ensureSession(primary) re-ran login() → discoverLinkedDistricts(),
|
|
136
|
-
// which re-creates the linked session. If discovery failed to restore it,
|
|
137
|
-
// surface a clear error rather than leaving callers with a null session.
|
|
138
|
-
if (!this.sessions.has(account.name)) {
|
|
175
|
+
await this.managers.get(primaryName).ensure();
|
|
176
|
+
const restored = this.managers.get(account.name).current;
|
|
177
|
+
if (!restored) {
|
|
139
178
|
throw new AuthFailedError(account.name, 'linked-district session could not be re-established via the primary district', { credentialHint: false });
|
|
140
179
|
}
|
|
141
|
-
return;
|
|
180
|
+
return restored;
|
|
181
|
+
}
|
|
182
|
+
// fetchproxy mode: the first primary login consumes the preloaded browser
|
|
183
|
+
// cookies instead of POSTing to verify.jsp. Consumed exactly once — after
|
|
184
|
+
// an expiry/invalidate there is nothing left to re-login with (below).
|
|
185
|
+
if (this.preloaded) {
|
|
186
|
+
const { cookieHeader, xsrfToken } = this.preloaded;
|
|
187
|
+
this.preloaded = null;
|
|
188
|
+
return { cookieHeader, xsrfToken };
|
|
142
189
|
}
|
|
143
190
|
// fetchproxy mode: empty primary creds, can't post to verify.jsp.
|
|
144
191
|
// The user must re-sign-in in the browser (and the next process start
|
|
145
|
-
// will pick up the fresh cookies).
|
|
192
|
+
// will pick up the fresh cookies). Marked permanent: the manager caches
|
|
193
|
+
// and rethrows this instead of retrying a login that can never succeed.
|
|
146
194
|
if (!account.username || !account.password) {
|
|
147
195
|
throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
|
|
148
|
-
'sign back into your IC portal in the browser and restart the MCP');
|
|
196
|
+
'sign back into your IC portal in the browser and restart the MCP', { permanent: true });
|
|
149
197
|
}
|
|
150
198
|
// ic_parent_api's pattern: single POST to verify.jsp, let the response
|
|
151
199
|
// set cookies. No pre-login GET needed (unlike OFW's Spring Security).
|
|
@@ -185,23 +233,21 @@ export class ICClient {
|
|
|
185
233
|
if (!cookies.cookieHeader) {
|
|
186
234
|
throw new AuthFailedError(account.name, 'login response missing session cookies');
|
|
187
235
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
session.xsrfToken = cookies.xsrfToken;
|
|
193
|
-
session.loggedInAt = Date.now();
|
|
236
|
+
const session = {
|
|
237
|
+
cookieHeader: cookies.cookieHeader,
|
|
238
|
+
xsrfToken: cookies.xsrfToken,
|
|
239
|
+
};
|
|
194
240
|
// Discover linked districts (CUPS SSO) — non-blocking, errors logged not
|
|
195
|
-
// thrown. By construction we only reach here for the primary
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
await this.discoverLinkedDistricts(account);
|
|
241
|
+
// thrown. By construction we only reach here for the primary: linked
|
|
242
|
+
// districts return early above and re-auth via the primary, so no
|
|
243
|
+
// `linkedTo` guard is needed.
|
|
244
|
+
await this.discoverLinkedDistricts(account, session);
|
|
245
|
+
return session;
|
|
199
246
|
}
|
|
200
|
-
async discoverLinkedDistricts(account) {
|
|
247
|
+
async discoverLinkedDistricts(account, session) {
|
|
201
248
|
try {
|
|
202
|
-
const session = this.sessions.get(account.name);
|
|
203
249
|
const baseHeaders = {
|
|
204
|
-
Cookie: session.
|
|
250
|
+
Cookie: session.cookieHeader,
|
|
205
251
|
Accept: 'application/json',
|
|
206
252
|
'Content-Type': 'application/json',
|
|
207
253
|
Referer: `${account.baseUrl}/campus/nav-wrapper/`,
|
|
@@ -261,7 +307,12 @@ export class ICClient {
|
|
|
261
307
|
console.error(`[ic] CUPS switch to ${linked.districtName}: no cookies`);
|
|
262
308
|
continue;
|
|
263
309
|
}
|
|
264
|
-
// Store synthetic account + session
|
|
310
|
+
// Store synthetic account + `seed()` the freshly-minted CUPS session
|
|
311
|
+
// into the linked district's own manager. `seed()` installs it with a
|
|
312
|
+
// fresh maxAgeMs clock and DETACHES any in-flight login — so when this
|
|
313
|
+
// discovery is itself being awaited by a mid-flight linked `login()`
|
|
314
|
+
// (the re-auth-through-primary path), that login's late resolution
|
|
315
|
+
// won't overwrite the seed. The manager must exist before we seed it.
|
|
265
316
|
const syntheticAccount = {
|
|
266
317
|
name: linked.districtName,
|
|
267
318
|
baseUrl: linkedBaseUrl,
|
|
@@ -270,13 +321,14 @@ export class ICClient {
|
|
|
270
321
|
password: '(linked)',
|
|
271
322
|
};
|
|
272
323
|
this.accounts.set(linked.districtName, syntheticAccount);
|
|
273
|
-
this.
|
|
274
|
-
|
|
324
|
+
this.linkedTo.set(linked.districtName, account.name);
|
|
325
|
+
if (!this.managers.has(linked.districtName)) {
|
|
326
|
+
this.managers.set(linked.districtName, this.makeManager(syntheticAccount));
|
|
327
|
+
}
|
|
328
|
+
this.managers.get(linked.districtName).seed({
|
|
329
|
+
cookieHeader: switchCookies.cookieHeader,
|
|
275
330
|
xsrfToken: switchCookies.xsrfToken,
|
|
276
|
-
loggedInAt: Date.now(),
|
|
277
|
-
loginInFlight: null,
|
|
278
331
|
});
|
|
279
|
-
this.linkedTo.set(linked.districtName, account.name);
|
|
280
332
|
console.error(`[ic] Linked district discovered: ${linked.districtName} (${linked.appName})`);
|
|
281
333
|
}
|
|
282
334
|
catch (e) {
|
|
@@ -310,14 +362,13 @@ export class ICClient {
|
|
|
310
362
|
const account = this.accounts.get(district);
|
|
311
363
|
if (!account)
|
|
312
364
|
throw new UnknownDistrictError(district, [...this.accounts.keys()]);
|
|
313
|
-
await this.
|
|
314
|
-
const session = this.sessions.get(account.name);
|
|
365
|
+
const session = await this.managers.get(account.name).ensure();
|
|
315
366
|
// Support both relative paths (/campus/...) and absolute URLs
|
|
316
367
|
// (e.g. report-card URLs from ic_list_documents come fully-qualified).
|
|
317
368
|
const url = /^https?:\/\//i.test(path) ? path : `${account.baseUrl}${path}`;
|
|
318
369
|
const res = await fetch(url, {
|
|
319
370
|
headers: {
|
|
320
|
-
Cookie: session.
|
|
371
|
+
Cookie: session.cookieHeader,
|
|
321
372
|
...(session.xsrfToken ? { 'X-XSRF-TOKEN': session.xsrfToken } : {}),
|
|
322
373
|
},
|
|
323
374
|
});
|
|
@@ -331,37 +382,31 @@ export class ICClient {
|
|
|
331
382
|
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
332
383
|
};
|
|
333
384
|
}
|
|
334
|
-
async doRequest(account, path, opts
|
|
335
|
-
const
|
|
385
|
+
async doRequest(account, path, opts) {
|
|
386
|
+
const mgr = this.managers.get(account.name);
|
|
336
387
|
const accept = opts.responseType === 'text' ? 'text/html, text/plain, */*' : 'application/json';
|
|
337
|
-
|
|
388
|
+
// withSession: on a 401 (detectExpiredSession, which also drops the
|
|
389
|
+
// primary + sibling sessions for a linked district), the manager
|
|
390
|
+
// invalidates this district, re-logs-in single-flight, and replays the
|
|
391
|
+
// request EXACTLY once.
|
|
392
|
+
const res = await mgr.withSession(async (session) => fetch(`${account.baseUrl}${path}`, {
|
|
338
393
|
method: opts.method ?? 'GET',
|
|
339
394
|
headers: {
|
|
340
|
-
Cookie: session.
|
|
395
|
+
Cookie: session.cookieHeader,
|
|
341
396
|
Accept: accept,
|
|
342
397
|
...(session.xsrfToken ? { 'X-XSRF-TOKEN': session.xsrfToken } : {}),
|
|
343
398
|
...(opts.headers ?? {}),
|
|
344
399
|
},
|
|
345
400
|
body: opts.body,
|
|
346
|
-
});
|
|
401
|
+
}));
|
|
347
402
|
if (res.status === 401) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
for (const [linked] of this.linkedTo) {
|
|
356
|
-
this.sessions.delete(linked);
|
|
357
|
-
}
|
|
358
|
-
const primaryAccount = this.accounts.get(primaryName);
|
|
359
|
-
await this.ensureSession(primaryAccount);
|
|
360
|
-
}
|
|
361
|
-
else {
|
|
362
|
-
await this.ensureSession(account);
|
|
363
|
-
}
|
|
364
|
-
return this.doRequest(account, path, opts, true);
|
|
403
|
+
// We only reach here when the re-login SUCCEEDED but the replayed request
|
|
404
|
+
// 401'd again (a fresh session the portal still rejects). If the re-login
|
|
405
|
+
// itself failed, the `onReplayLoginError` hook already rethrew that
|
|
406
|
+
// error out of `withSession` — carrying the actionable message (e.g.
|
|
407
|
+
// fetchproxy's "sign back into your IC portal") — so control never
|
|
408
|
+
// reaches this branch in that case.
|
|
409
|
+
throw new SessionExpiredError(account.name);
|
|
365
410
|
}
|
|
366
411
|
if (res.status >= 500)
|
|
367
412
|
throw new PortalUnreachableError(account.name, res.status);
|
|
@@ -380,24 +425,17 @@ export class ICClient {
|
|
|
380
425
|
*/
|
|
381
426
|
function parseSetCookies(headers) {
|
|
382
427
|
// Prefer the structured getSetCookie() split (one cookie per array entry,
|
|
383
|
-
// what real Node fetch Responses provide).
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// splitter. parseCookieJar then does the jar logic: drop deletion markers
|
|
428
|
+
// what real Node fetch Responses provide). When it's unavailable, hand the
|
|
429
|
+
// joined `set-cookie` string to parseCookieJar, which splits it defensively
|
|
430
|
+
// (safe around the commas inside `Expires` dates, unlike a naive comma
|
|
431
|
+
// split). parseCookieJar then does the jar logic: drop deletion markers
|
|
388
432
|
// (Max-Age=0 / expired Expires) and empty values, dedupe by name. That's
|
|
389
433
|
// load-bearing so the synthesized Cookie header never carries both the
|
|
390
434
|
// `appName=` deletion and the real value (IC rejects "conflicting app name").
|
|
391
435
|
const raw = headers.getSetCookie?.() ?? [];
|
|
392
|
-
const
|
|
393
|
-
const { cookies, cookieHeader } = parseCookieJar(entries);
|
|
436
|
+
const { cookies, cookieHeader } = parseCookieJar(raw.length > 0 ? raw : headers.get('set-cookie'));
|
|
394
437
|
return { cookieHeader, xsrfToken: cookies['XSRF-TOKEN'] ?? '' };
|
|
395
438
|
}
|
|
396
|
-
function splitFallback(header) {
|
|
397
|
-
if (!header)
|
|
398
|
-
return [];
|
|
399
|
-
return header.split(',').map((s) => s.trim());
|
|
400
|
-
}
|
|
401
439
|
export class UnknownDistrictError extends Error {
|
|
402
440
|
district;
|
|
403
441
|
available;
|
|
@@ -411,11 +449,19 @@ export class UnknownDistrictError extends Error {
|
|
|
411
449
|
export class AuthFailedError extends Error {
|
|
412
450
|
district;
|
|
413
451
|
reason;
|
|
452
|
+
/**
|
|
453
|
+
* A *permanent* configuration failure: retrying the login inside this
|
|
454
|
+
* process can never succeed (e.g. fetchproxy mode with no creds — the user
|
|
455
|
+
* must re-sign-in in the browser and restart). CookieSessionManager caches
|
|
456
|
+
* and rethrows permanent errors instead of retrying.
|
|
457
|
+
*/
|
|
458
|
+
permanent;
|
|
414
459
|
/**
|
|
415
460
|
* @param opts.credentialHint When `false`, the message omits the
|
|
416
461
|
* "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
|
|
417
462
|
* credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
|
|
418
463
|
* failure) and pointing the user at their creds would be misleading.
|
|
464
|
+
* @param opts.permanent See {@link AuthFailedError.permanent}.
|
|
419
465
|
*/
|
|
420
466
|
constructor(district, reason, opts) {
|
|
421
467
|
const detail = reason ? ` (${reason})` : '';
|
|
@@ -427,6 +473,7 @@ export class AuthFailedError extends Error {
|
|
|
427
473
|
this.district = district;
|
|
428
474
|
this.reason = reason;
|
|
429
475
|
this.name = 'AuthFailedError';
|
|
476
|
+
this.permanent = opts?.permanent ?? false;
|
|
430
477
|
}
|
|
431
478
|
}
|
|
432
479
|
export class PortalUnreachableError extends Error {
|
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ catch (e) {
|
|
|
57
57
|
// actionable stderr message (banner) instead of a crash loop.
|
|
58
58
|
const COMMON = {
|
|
59
59
|
name: 'infinitecampus',
|
|
60
|
-
version: '2.3.
|
|
60
|
+
version: '2.3.6', // x-release-please-version
|
|
61
61
|
};
|
|
62
62
|
if (account) {
|
|
63
63
|
const client = new ICClient(account, { preloaded });
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
-
/**
|
|
3
|
-
* Wrap a value as an MCP text content block — the standard tool return shape.
|
|
4
|
-
* Thin re-export of mcp-utils' `textResult` so the whole fleet shares one
|
|
5
|
-
* pretty-printed-JSON formatter; the local name is kept to avoid churning
|
|
6
|
-
* every tool call site.
|
|
7
|
-
*/
|
|
8
|
-
export function textContent(data) {
|
|
9
|
-
return textResult(data);
|
|
10
|
-
}
|
|
11
2
|
/** Detect "IC 404 ..." errors thrown by ICClient.request for endpoints that don't exist. */
|
|
12
3
|
export function is404(e) {
|
|
13
4
|
return e instanceof Error && e.message.startsWith('IC 404 ');
|
|
14
5
|
}
|
|
15
6
|
/** Build a FeatureDisabled warning content block. */
|
|
16
7
|
export function featureDisabled(feature, district, data = []) {
|
|
17
|
-
return
|
|
8
|
+
return textResult({ warning: 'FeatureDisabled', feature, district, data });
|
|
18
9
|
}
|
|
19
10
|
/** Fetch the students list and find the one matching studentId. Returns null if not found. */
|
|
20
11
|
export async function findStudent(client, district, studentId) {
|
|
@@ -23,7 +14,7 @@ export async function findStudent(client, district, studentId) {
|
|
|
23
14
|
}
|
|
24
15
|
/** Standard error content block for when studentId doesn't match any student. */
|
|
25
16
|
export function studentNotFound(studentId) {
|
|
26
|
-
return
|
|
17
|
+
return textResult({ error: 'StudentNotFound', studentId });
|
|
27
18
|
}
|
|
28
19
|
/**
|
|
29
20
|
* Check a feature flag via the district's displayOptions allow-list for the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { findStudent, studentNotFound, featureDisabled, is404, toArray, checkFeatureDisabled } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string().describe('Student personID from ic_list_students'),
|
|
@@ -48,6 +49,6 @@ export function registerAssessmentTools(server, client) {
|
|
|
48
49
|
if (feature404 && result.length === 0) {
|
|
49
50
|
return featureDisabled('assessments', args.district);
|
|
50
51
|
}
|
|
51
|
-
return
|
|
52
|
+
return textResult(result);
|
|
52
53
|
});
|
|
53
54
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import { textContent } from './_shared.js';
|
|
3
3
|
const argsSchema = z.object({
|
|
4
4
|
district: z.string(),
|
|
5
5
|
studentId: z.string(),
|
|
@@ -33,6 +33,6 @@ export function registerAssignmentTools(server, client) {
|
|
|
33
33
|
if (args.missingOnly) {
|
|
34
34
|
data = data.filter((a) => a.missing);
|
|
35
35
|
}
|
|
36
|
-
return
|
|
36
|
+
return textResult(data);
|
|
37
37
|
});
|
|
38
38
|
}
|
package/dist/tools/attendance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string(),
|
|
@@ -70,7 +71,7 @@ export function registerAttendanceTools(server, client) {
|
|
|
70
71
|
results.push({ ...entry, terms: trimmedTerms });
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
|
-
return
|
|
74
|
+
return textResult(results);
|
|
74
75
|
}
|
|
75
76
|
catch (e) {
|
|
76
77
|
if (is404(e))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string().describe('Student personID from ic_list_students'),
|
|
@@ -78,7 +79,7 @@ export function registerAttendanceEventsTools(server, client) {
|
|
|
78
79
|
results.push(trimmed);
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
|
-
return
|
|
82
|
+
return textResult(results);
|
|
82
83
|
}
|
|
83
84
|
catch (e) {
|
|
84
85
|
if (is404(e))
|
package/dist/tools/behavior.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { is404, featureDisabled, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string(),
|
|
@@ -26,7 +27,7 @@ export function registerBehaviorTools(server, client) {
|
|
|
26
27
|
params.set('endDate', args.until);
|
|
27
28
|
try {
|
|
28
29
|
const data = await client.request(args.district, `/campus/resources/portal/behavior?${params}`);
|
|
29
|
-
return
|
|
30
|
+
return textResult(data);
|
|
30
31
|
}
|
|
31
32
|
catch (e) {
|
|
32
33
|
if (is404(e))
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { findStudent, studentNotFound, toArray } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string().describe('Student personID from ic_list_students'),
|
|
@@ -63,6 +64,6 @@ export function registerCalendarTools(server, client) {
|
|
|
63
64
|
terms: trimmedTerms,
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
|
-
return
|
|
67
|
+
return textResult(result);
|
|
67
68
|
});
|
|
68
69
|
}
|
package/dist/tools/districts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
2
2
|
export function registerDistrictTools(server, client) {
|
|
3
3
|
server.registerTool('ic_list_districts', {
|
|
4
4
|
description: 'List Infinite Campus districts configured for this MCP server. Returns names + base URLs (no credentials).',
|
|
@@ -6,6 +6,6 @@ export function registerDistrictTools(server, client) {
|
|
|
6
6
|
}, async () => {
|
|
7
7
|
await client.ensureDiscovery();
|
|
8
8
|
const data = client.listDistricts();
|
|
9
|
-
return
|
|
9
|
+
return textResult(data);
|
|
10
10
|
});
|
|
11
11
|
}
|
package/dist/tools/documents.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { is404, featureDisabled, toArray, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
|
|
3
4
|
const listArgs = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string(),
|
|
@@ -39,7 +40,7 @@ export function registerDocumentTools(server, client) {
|
|
|
39
40
|
out.endYear = d.endYear;
|
|
40
41
|
return out;
|
|
41
42
|
});
|
|
42
|
-
return
|
|
43
|
+
return textResult(trimmed);
|
|
43
44
|
}
|
|
44
45
|
catch (e) {
|
|
45
46
|
if (is404(e))
|
|
@@ -57,11 +58,11 @@ export function registerDocumentTools(server, client) {
|
|
|
57
58
|
const meta = await client.download(args.district, args.documentId, args.destinationPath, {
|
|
58
59
|
overwrite: args.overwrite ?? false,
|
|
59
60
|
});
|
|
60
|
-
return
|
|
61
|
+
return textResult(meta);
|
|
61
62
|
}
|
|
62
63
|
catch (e) {
|
|
63
64
|
if (e instanceof Error && e.message.startsWith('IC download 404')) {
|
|
64
|
-
return
|
|
65
|
+
return textResult({ warning: 'FeatureDisabled', feature: 'documents', district: args.district });
|
|
65
66
|
}
|
|
66
67
|
throw e;
|
|
67
68
|
}
|
package/dist/tools/features.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { findStudent, studentNotFound } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string().describe('Student personID from ic_list_students'),
|
|
@@ -24,6 +25,6 @@ export function registerFeaturesTools(server, client) {
|
|
|
24
25
|
features,
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
|
-
return
|
|
28
|
+
return textResult(result);
|
|
28
29
|
});
|
|
29
30
|
}
|
package/dist/tools/fees.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
+
import { is404, featureDisabled } from './_shared.js';
|
|
3
4
|
const argsSchema = z.object({
|
|
4
5
|
district: z.string(),
|
|
5
6
|
studentId: z.string().describe('Student personID from ic_list_students'),
|
|
@@ -38,6 +39,6 @@ export function registerFeeTools(server, client) {
|
|
|
38
39
|
feeAssignments: assignments.ok ? assignments.value : [],
|
|
39
40
|
...(issues.length > 0 ? { issues } : {}),
|
|
40
41
|
};
|
|
41
|
-
return
|
|
42
|
+
return textResult(response);
|
|
42
43
|
});
|
|
43
44
|
}
|