infinitecampus-mcp 2.4.4 → 2.5.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 +950 -158
- package/dist/client.js +161 -13
- package/dist/index.js +1 -1
- package/dist/session-cache.js +95 -0
- package/mint.yaml +72 -0
- package/package.json +10 -7
- package/server.json +14 -2
package/dist/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile, stat } from 'fs/promises';
|
|
|
2
2
|
import { dirname } from 'path';
|
|
3
3
|
import { parseCookieJar } from '@chrischall/mcp-utils';
|
|
4
4
|
import { createCookieSessionManager } from '@chrischall/mcp-utils/session';
|
|
5
|
+
import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
|
|
5
6
|
const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
|
|
6
7
|
export class ICClient {
|
|
7
8
|
accounts = new Map();
|
|
@@ -25,6 +26,32 @@ export class ICClient {
|
|
|
25
26
|
* first call to `ensureDiscovery()`.
|
|
26
27
|
*/
|
|
27
28
|
fetchproxyMode = false;
|
|
29
|
+
/**
|
|
30
|
+
* Whether CUPS linked-district discovery has run IN THIS PROCESS. Not "is
|
|
31
|
+
* there a session" — a session restored from the on-disk cache is real while
|
|
32
|
+
* discovery has not run, and conflating the two leaves linked districts
|
|
33
|
+
* invisible. See {@link InfiniteCampusClient.ensureDiscovery}.
|
|
34
|
+
*/
|
|
35
|
+
discoveryDone = false;
|
|
36
|
+
/**
|
|
37
|
+
* The in-flight discovery, so concurrent callers await the SAME one.
|
|
38
|
+
*
|
|
39
|
+
* A bare boolean is not enough: `discoveryDone` flips as soon as the CUPS
|
|
40
|
+
* probe answers, but `accounts`/`linkedTo` are only populated further down.
|
|
41
|
+
* A second request for a linked district landing in that window would read
|
|
42
|
+
* the flag as done, find `accounts` still empty, and throw
|
|
43
|
+
* UnknownDistrictError for a district that was moments from existing.
|
|
44
|
+
*/
|
|
45
|
+
discoveryInFlight = null;
|
|
46
|
+
/**
|
|
47
|
+
* The session the last discovery attempt used.
|
|
48
|
+
*
|
|
49
|
+
* `login()` runs discovery on every fresh mint. If that probe fails without
|
|
50
|
+
* latching, `ensureDiscovery` would immediately re-probe with the very same
|
|
51
|
+
* session and fail identically — two requests for one answer. Retrying is
|
|
52
|
+
* right; retrying the same session inside one call is not.
|
|
53
|
+
*/
|
|
54
|
+
lastDiscoverySession = null;
|
|
28
55
|
/**
|
|
29
56
|
* fetchproxy lift. Called by the primary manager's login — on the first
|
|
30
57
|
* request AND on every renewal, which is what keeps the path alive past
|
|
@@ -45,11 +72,16 @@ export class ICClient {
|
|
|
45
72
|
constructor(account, opts = {}) {
|
|
46
73
|
this.accounts.set(account.name, account);
|
|
47
74
|
this.primaryName = account.name;
|
|
48
|
-
|
|
75
|
+
// BEFORE makeManager: it reads `fetchproxyMode` to decide what a cached
|
|
76
|
+
// record binds to. Setting it afterwards left the primary manager built with
|
|
77
|
+
// browserBacked:false, and fetchproxy accounts carry empty credentials — so
|
|
78
|
+
// the binding came back null and the mode this cache helps MOST was the one
|
|
79
|
+
// mode that never got one.
|
|
49
80
|
if (opts.refreshSession) {
|
|
50
81
|
this.refreshSession = opts.refreshSession;
|
|
51
82
|
this.fetchproxyMode = true;
|
|
52
83
|
}
|
|
84
|
+
this.managers.set(account.name, this.makeManager(account));
|
|
53
85
|
}
|
|
54
86
|
/**
|
|
55
87
|
* Build the per-district CookieSessionManager.
|
|
@@ -69,8 +101,24 @@ export class ICClient {
|
|
|
69
101
|
* bookkeeping used to provide.
|
|
70
102
|
*/
|
|
71
103
|
makeManager(account) {
|
|
104
|
+
// PRIMARY ONLY. A linked district's session is seeded by the primary's
|
|
105
|
+
// discovery and has no `linkedTo` entry behind it until that runs, so a
|
|
106
|
+
// linked record restored on its own would be a session belonging to nothing.
|
|
107
|
+
// Caching the primary is what makes the expensive part free; discovery
|
|
108
|
+
// re-runs from the restored session and re-establishes the rest.
|
|
109
|
+
const cache = account.name === this.primaryName
|
|
110
|
+
? createSessionCache({
|
|
111
|
+
district: account.name,
|
|
112
|
+
baseUrl: account.baseUrl,
|
|
113
|
+
username: account.username,
|
|
114
|
+
password: account.password,
|
|
115
|
+
browserBacked: this.fetchproxyMode,
|
|
116
|
+
})
|
|
117
|
+
: null;
|
|
72
118
|
return createCookieSessionManager({
|
|
73
119
|
login: () => this.login(account),
|
|
120
|
+
persistence: cache ?? undefined,
|
|
121
|
+
onPersistError: reportCacheWriteFailure,
|
|
74
122
|
isExpired: (res) => this.detectExpiredSession(account.name, res),
|
|
75
123
|
isPermanentError: (e) => e instanceof AuthFailedError && e.permanent,
|
|
76
124
|
maxAgeMs: SESSION_TTL_MS,
|
|
@@ -101,13 +149,60 @@ export class ICClient {
|
|
|
101
149
|
return true;
|
|
102
150
|
}
|
|
103
151
|
async ensureDiscovery() {
|
|
104
|
-
// Ensure primary account is logged in, which triggers CUPS linked-district discovery
|
|
105
152
|
const session = await this.managers.get(this.primaryName).ensure();
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
153
|
+
// This used to be a no-op, resting on "a cached primary session means
|
|
154
|
+
// discovery already ran for it". That held while the only cache was in
|
|
155
|
+
// memory: a session in hand implied THIS process had minted it, and every
|
|
156
|
+
// mint runs discovery. A session RESTORED from disk breaks it — the session
|
|
157
|
+
// is real, discovery never ran here, and `accounts`/`linkedTo` are empty,
|
|
158
|
+
// so linked districts would silently not exist.
|
|
159
|
+
//
|
|
160
|
+
// Tracking it explicitly is the fix, and it is the right shape regardless
|
|
161
|
+
// of persistence: the invariant is "discovery has run in this process",
|
|
162
|
+
// which is now stated rather than inferred from a side effect.
|
|
163
|
+
if (this.discoveryDone)
|
|
164
|
+
return;
|
|
165
|
+
// ORDER MATTERS. The in-flight check comes first: a concurrent caller must
|
|
166
|
+
// AWAIT a discovery already running, not skip it — returning early would
|
|
167
|
+
// let it proceed on a half-populated `accounts` and throw
|
|
168
|
+
// UnknownDistrictError for a district that was moments from existing. The
|
|
169
|
+
// same-session guard below returns early by design, so putting it first
|
|
170
|
+
// would reintroduce exactly that race.
|
|
171
|
+
if (this.discoveryInFlight !== null) {
|
|
172
|
+
await this.discoveryInFlight;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Cleared on settle so a transiently-failed probe can be retried later.
|
|
176
|
+
// Non-null like every other primary lookup in this file: the constructor
|
|
177
|
+
// registers the primary account and sets primaryName from it.
|
|
178
|
+
const primary = this.accounts.get(this.primaryName);
|
|
179
|
+
const manager = this.managers.get(this.primaryName);
|
|
180
|
+
this.discoveryInFlight = (async () => {
|
|
181
|
+
// Reaching here means discovery has not latched. Both routes out are the
|
|
182
|
+
// same: get a FRESH session, because `login()` runs discovery itself on
|
|
183
|
+
// every mint — so re-minting IS the retry. Coverage made that plain, by
|
|
184
|
+
// showing the explicit re-probes this once had were unreachable: the
|
|
185
|
+
// latch was always already set by login's own discovery.
|
|
186
|
+
if (this.lastDiscoverySession === session) {
|
|
187
|
+
// Already probed this exact session and it did not latch, so it is
|
|
188
|
+
// spent. A re-mint (after the failed probe invalidated it) runs
|
|
189
|
+
// discovery against the new one.
|
|
190
|
+
await manager.ensure();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (await this.discoverLinkedDistricts(primary, session))
|
|
194
|
+
return;
|
|
195
|
+
// The probe rejected that session, so it has just been invalidated.
|
|
196
|
+
// Re-minting here rather than on the NEXT call is the whole point: before
|
|
197
|
+
// this, the first ic_list_districts after a restart silently omitted
|
|
198
|
+
// linked districts and the first request('<linked>') threw
|
|
199
|
+
// UnknownDistrictError. Once — login's discovery either succeeds or the
|
|
200
|
+
// problem is not staleness.
|
|
201
|
+
await manager.ensure();
|
|
202
|
+
})().finally(() => {
|
|
203
|
+
this.discoveryInFlight = null;
|
|
204
|
+
});
|
|
205
|
+
await this.discoveryInFlight;
|
|
111
206
|
}
|
|
112
207
|
listDistricts() {
|
|
113
208
|
return [...this.accounts.values()].map((a) => ({
|
|
@@ -243,7 +338,20 @@ export class ICClient {
|
|
|
243
338
|
await this.discoverLinkedDistricts(account, session);
|
|
244
339
|
return session;
|
|
245
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* @returns whether the CUPS probe ANSWERED. `false` means the session was
|
|
343
|
+
* rejected (and has been invalidated), so the caller can mint a fresh one and
|
|
344
|
+
* try once more — without that, recovery lands a call too late and the first
|
|
345
|
+
* `ic_list_districts` after a restart silently omits linked districts.
|
|
346
|
+
*/
|
|
246
347
|
async discoverLinkedDistricts(account, session) {
|
|
348
|
+
this.lastDiscoverySession = session;
|
|
349
|
+
// The latch is set where the CUPS probe ANSWERS, not here. Setting it up
|
|
350
|
+
// front conflated two very different silences: "this district has no CUPS"
|
|
351
|
+
// (permanent — never retry) and "the session was dead so the probe 401'd"
|
|
352
|
+
// (transient — must retry). A restored-but-idle-expired session hit the
|
|
353
|
+
// second, latched the flag, and left linked districts invisible for the
|
|
354
|
+
// life of the process.
|
|
247
355
|
try {
|
|
248
356
|
const baseHeaders = {
|
|
249
357
|
Cookie: session.cookieHeader,
|
|
@@ -255,18 +363,45 @@ export class ICClient {
|
|
|
255
363
|
};
|
|
256
364
|
// 1. Get linked accounts
|
|
257
365
|
const laRes = await fetch(`${account.baseUrl}/campus/api/campus/authentication/cups/linkedAccounts`, { headers: baseHeaders });
|
|
258
|
-
if (!laRes.ok)
|
|
259
|
-
|
|
366
|
+
if (!laRes.ok) {
|
|
367
|
+
// A 401/403 here does not mean "no CUPS" — it means the session this
|
|
368
|
+
// probe used is dead. Leaving it in place was the half-fix: the latch
|
|
369
|
+
// was gone, but `lastDiscoverySession` then blocked every retry, so
|
|
370
|
+
// linked districts stayed invisible and request('<linked>') threw
|
|
371
|
+
// UnknownDistrictError until some unrelated primary call happened to
|
|
372
|
+
// 401. Dropping the session is what makes the retry real — the next
|
|
373
|
+
// ensure() mints a fresh one and discovery runs against that.
|
|
374
|
+
if (laRes.status === 401 || laRes.status === 403) {
|
|
375
|
+
// Invalidate ONLY. `lastDiscoverySession` deliberately keeps pointing
|
|
376
|
+
// at this dead session: it stops ensureDiscovery re-probing the same
|
|
377
|
+
// one immediately (two requests for one answer), while the
|
|
378
|
+
// invalidation guarantees the NEXT call mints a different session
|
|
379
|
+
// object — which the guard then lets through as a genuine retry.
|
|
380
|
+
this.managers.get(account.name)?.invalidate();
|
|
381
|
+
return false; // dead session — the caller can retry with a fresh one
|
|
382
|
+
}
|
|
383
|
+
// The session was fine — this is a 404 (no CUPS here) or a 5xx. Either
|
|
384
|
+
// way discovery did NOT complete, so it stays unlatched and a later
|
|
385
|
+
// call retries. `true` says only "do not re-mint the session".
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
260
388
|
const laData = await laRes.json();
|
|
261
|
-
if (!laData.accounts?.length)
|
|
262
|
-
|
|
389
|
+
if (!laData.accounts?.length) {
|
|
390
|
+
// The one genuinely permanent answer in this method: the endpoint
|
|
391
|
+
// responded and said there are no linked accounts. Latch it.
|
|
392
|
+
this.discoveryDone = true;
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
263
395
|
// 2. Get original district info (needed for all linked accounts)
|
|
264
396
|
const [origRes, currRes] = await Promise.all([
|
|
265
397
|
fetch(`${account.baseUrl}/campus/api/campus/user/userAccountSwitch/originalDistrict`, { headers: baseHeaders }),
|
|
266
398
|
fetch(`${account.baseUrl}/campus/api/campus/districts/current`, { headers: baseHeaders }),
|
|
267
399
|
]);
|
|
400
|
+
// Same shape as above: the session answered, so no re-mint — but
|
|
401
|
+
// discovery did not complete, so no latch either, and a later call
|
|
402
|
+
// retries.
|
|
268
403
|
if (!origRes.ok || !currRes.ok)
|
|
269
|
-
return;
|
|
404
|
+
return true;
|
|
270
405
|
const origData = await origRes.json();
|
|
271
406
|
const currData = await currRes.json();
|
|
272
407
|
// 3. For each linked account, get CUPS token and authenticate
|
|
@@ -334,10 +469,23 @@ export class ICClient {
|
|
|
334
469
|
console.error(`[ic] CUPS flow failed for ${linked.districtName}: ${e instanceof Error ? e.message : e}`);
|
|
335
470
|
}
|
|
336
471
|
}
|
|
472
|
+
// Latched HERE, at the end of a run that got all the way through, rather
|
|
473
|
+
// than at the first probe. Everything between — originalDistrict,
|
|
474
|
+
// districts/current, the per-linked switch loop — can fail transiently,
|
|
475
|
+
// and latching early wrote linked districts off permanently on any of
|
|
476
|
+
// them. A per-district CUPS failure inside the loop is caught and logged
|
|
477
|
+
// above, so it does not block the latch: that one district is genuinely
|
|
478
|
+
// unavailable, the rest were established.
|
|
479
|
+
this.discoveryDone = true;
|
|
480
|
+
return true;
|
|
337
481
|
}
|
|
338
482
|
catch (e) {
|
|
339
|
-
// Don't fail primary login on linked-district errors
|
|
483
|
+
// Don't fail primary login on linked-district errors, and do NOT latch —
|
|
484
|
+
// an exception here means discovery did not complete, so a later call
|
|
485
|
+
// with a live session should try again. Reported as "answered" so the
|
|
486
|
+
// caller does not re-mint: the session was fine, something else broke.
|
|
340
487
|
console.error(`[ic] Linked district discovery failed: ${e instanceof Error ? e.message : e}`);
|
|
488
|
+
return true;
|
|
341
489
|
}
|
|
342
490
|
}
|
|
343
491
|
async download(district, path, destinationPath, opts = {}) {
|
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.
|
|
60
|
+
version: '2.5.0', // x-release-please-version
|
|
61
61
|
};
|
|
62
62
|
if (account) {
|
|
63
63
|
const client = new ICClient(account, { refreshSession });
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
|
|
2
|
+
import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
|
|
3
|
+
/**
|
|
4
|
+
* Where the primary district's session is cached between runs.
|
|
5
|
+
*
|
|
6
|
+
* Per district, because a deployment can point at more than one Infinite Campus
|
|
7
|
+
* instance and their sessions are unrelated — one shared file would have each
|
|
8
|
+
* clobber the other, and every record would then fail its own binding check.
|
|
9
|
+
*/
|
|
10
|
+
export function sessionCachePath(env = process.env, district) {
|
|
11
|
+
const seg = (district ?? '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64);
|
|
12
|
+
return resolveStateFile({
|
|
13
|
+
env,
|
|
14
|
+
envVar: 'IC_SESSION_FILE',
|
|
15
|
+
subdir: '.infinitecampus-mcp',
|
|
16
|
+
fileName: seg === '' ? 'session.json' : `session-${seg}.json`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Guard the stored envelope: a usable cookie header, and a login time. */
|
|
20
|
+
function isStored(raw) {
|
|
21
|
+
if (raw === null || typeof raw !== 'object')
|
|
22
|
+
return false;
|
|
23
|
+
const r = raw;
|
|
24
|
+
if (typeof r.sessionAt !== 'number')
|
|
25
|
+
return false;
|
|
26
|
+
const s = r.session;
|
|
27
|
+
if (s === null || typeof s !== 'object')
|
|
28
|
+
return false;
|
|
29
|
+
// An empty header is not a session — restoring one would look authenticated
|
|
30
|
+
// and then 401 every request until the expiry heuristic caught it.
|
|
31
|
+
if (typeof s.cookieHeader !== 'string' || s.cookieHeader === '')
|
|
32
|
+
return false;
|
|
33
|
+
// Required rather than optional: a restored session missing it would read as
|
|
34
|
+
// authenticated and then fail every write that needs the X-XSRF-TOKEN header.
|
|
35
|
+
return typeof s.xsrfToken === 'string';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The session cache for the PRIMARY district, or `null` when disabled.
|
|
39
|
+
*
|
|
40
|
+
* Only the primary is cached. Linked districts hold synthetic `(linked)`
|
|
41
|
+
* credentials and are authenticated by the primary's CUPS SSO switch, then
|
|
42
|
+
* `seed()`ed into their managers by discovery — so a linked session restored on
|
|
43
|
+
* its own would be a session with no `linkedTo` entry behind it. Caching the
|
|
44
|
+
* primary is what makes the expensive part free; discovery re-runs from the
|
|
45
|
+
* restored session and re-establishes the rest.
|
|
46
|
+
*
|
|
47
|
+
* Fetchproxy mode deliberately does NOT cache — see the note in the body. It
|
|
48
|
+
* would benefit most, and there is no identity to bind a record to, so a cached
|
|
49
|
+
* session could be restored for the wrong account.
|
|
50
|
+
*/
|
|
51
|
+
export function createSessionCache(opts) {
|
|
52
|
+
const env = opts.env ?? process.env;
|
|
53
|
+
if (!parseBoolEnv('IC_SESSION_CACHE', { env, default: true }))
|
|
54
|
+
return null;
|
|
55
|
+
const username = opts.username ?? readEnvVar('IC_USERNAME', { env });
|
|
56
|
+
const password = opts.password ?? readEnvVar('IC_PASSWORD', { env });
|
|
57
|
+
// Fetchproxy mode does NOT cache, and the reasoning is worth keeping.
|
|
58
|
+
//
|
|
59
|
+
// The previous review was right that it would benefit most — that mode cannot
|
|
60
|
+
// re-login unaided, so a cached session is what lets a cold start proceed with
|
|
61
|
+
// no browser. But there is nothing to bind a record to: the credentials are
|
|
62
|
+
// empty by design and the identity lives in the browser, known only after a
|
|
63
|
+
// lift. Binding to ['fetchproxy', baseUrl] means signing into a DIFFERENT IC
|
|
64
|
+
// account at the same instance restores the previous account's session, and
|
|
65
|
+
// the server then acts as the wrong parent against another family's student
|
|
66
|
+
// data. That is a worse outcome than a browser round-trip, so this declines.
|
|
67
|
+
//
|
|
68
|
+
// Making it safe needs an identity check on restore, which is a network call
|
|
69
|
+
// the sync load() path cannot make — a separate change, not a tweak here.
|
|
70
|
+
if (opts.browserBacked === true)
|
|
71
|
+
return null;
|
|
72
|
+
const boundTo = username && password
|
|
73
|
+
? ['login', opts.baseUrl, username.trim().toLowerCase(), password].join('\u0000')
|
|
74
|
+
: null;
|
|
75
|
+
if (boundTo === null)
|
|
76
|
+
return null;
|
|
77
|
+
return createFileStatePersistence({
|
|
78
|
+
filePath: sessionCachePath(env, opts.district),
|
|
79
|
+
boundTo,
|
|
80
|
+
validate: (raw) => (isStored(raw) ? raw : null),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Report a cache write that failed. Not fatal in env mode: the session is
|
|
85
|
+
* re-mintable from the credentials. In fetchproxy mode a lost write costs the
|
|
86
|
+
* next start a trip through the browser, which is worse but still recoverable —
|
|
87
|
+
* and failing the request would not make the browser any more available.
|
|
88
|
+
*
|
|
89
|
+
* stderr only; stdout is the JSON-RPC channel.
|
|
90
|
+
*/
|
|
91
|
+
export function reportCacheWriteFailure(err) {
|
|
92
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
93
|
+
console.error(`[infinitecampus-mcp] could not cache the session (${detail}); continuing without ` +
|
|
94
|
+
'the cache — every restart will authenticate again until this is fixed.');
|
|
95
|
+
}
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
name: Infinite Campus
|
|
3
|
+
slug: infinitecampus
|
|
4
|
+
summary: >-
|
|
5
|
+
Infinite Campus (Campus Parent) MCP — single-account with CUPS
|
|
6
|
+
auto-discovery. Two auth paths: IC_USERNAME/IC_PASSWORD, or sign in via
|
|
7
|
+
the fetchproxy browser extension.
|
|
8
|
+
#
|
|
9
|
+
# Hosting note (a comment, not user-facing summary text): this is a
|
|
10
|
+
# BROWSER-BRIDGE MCP — it reaches its site through the user's signed-in
|
|
11
|
+
# tab via the fetchproxy bridge. A bridged registration also needs runtime
|
|
12
|
+
# `fly-shared`, `bridge: true` and a `bridgePortEnv`, which are registration
|
|
13
|
+
# fields this manifest has no schema for (set them over the control API).
|
|
14
|
+
# `state.dataDir` below is required for `bridge`.
|
|
15
|
+
env:
|
|
16
|
+
- name: IC_BASE_URL
|
|
17
|
+
required: true
|
|
18
|
+
help: >-
|
|
19
|
+
Campus Parent portal base URL, e.g. https://campus.district.k12.example.us
|
|
20
|
+
- name: IC_DISTRICT
|
|
21
|
+
required: true
|
|
22
|
+
help: >-
|
|
23
|
+
Infinite Campus district app name (appName path segment from the portal
|
|
24
|
+
URL)
|
|
25
|
+
- name: IC_USERNAME
|
|
26
|
+
required: false
|
|
27
|
+
help: >-
|
|
28
|
+
Campus Parent portal username. Optional: leave unset to use the fetchproxy
|
|
29
|
+
browser-extension fallback.
|
|
30
|
+
- name: IC_PASSWORD
|
|
31
|
+
secret: true
|
|
32
|
+
required: false
|
|
33
|
+
help: >-
|
|
34
|
+
Campus Parent portal password. Required only when IC_USERNAME is set.
|
|
35
|
+
- name: IC_NAME
|
|
36
|
+
required: false
|
|
37
|
+
help: >-
|
|
38
|
+
Friendly name for the district (defaults to district appname)
|
|
39
|
+
- name: IC_DISABLE_FETCHPROXY
|
|
40
|
+
required: false
|
|
41
|
+
help: >-
|
|
42
|
+
Set to 1 to skip the fetchproxy browser-extension fallback (missing creds
|
|
43
|
+
become a hard error — useful in headless CI).
|
|
44
|
+
- name: IC_SESSION_CACHE
|
|
45
|
+
required: false
|
|
46
|
+
help: >-
|
|
47
|
+
Set to false to disable the on-disk session cache and authenticate on
|
|
48
|
+
every process start. Defaults to enabled.
|
|
49
|
+
- name: IC_SESSION_FILE
|
|
50
|
+
required: false
|
|
51
|
+
help: >-
|
|
52
|
+
Absolute path for the session cache file. Defaults to
|
|
53
|
+
$MCP_DATA_DIR/.infinitecampus-mcp/session-<district>.json.
|
|
54
|
+
state:
|
|
55
|
+
dataDir: true
|
|
56
|
+
reason: >-
|
|
57
|
+
Two things live here. The fetchproxy identity is at
|
|
58
|
+
$HOME/.fetchproxy/identity/<name>.json and the pair code derives from it —
|
|
59
|
+
without a persistent $HOME every cold start mints a fresh identity and
|
|
60
|
+
re-prompts pairing in the browser, and the API refuses bridge without it.
|
|
61
|
+
The primary district's session is also cached at
|
|
62
|
+
$MCP_DATA_DIR/.infinitecampus-mcp/session-<district>.json (0600) when
|
|
63
|
+
credentials are configured, so a restart reuses it instead of logging in
|
|
64
|
+
again. Fetchproxy sessions are deliberately NOT cached: they carry no
|
|
65
|
+
identity to bind a record to, so a restored one could belong to a different
|
|
66
|
+
IC account at the same instance.
|
|
67
|
+
# egress: intentionally NOT declared.
|
|
68
|
+
#
|
|
69
|
+
# This server's upstream host is per-deployment, so no fixed allowlist can
|
|
70
|
+
# be correct here. On the isolated tier, allow:
|
|
71
|
+
# your district's Infinite Campus host, from IC_BASE_URL (districts are each on their own host, e.g. <district>.infinitecampus.org)
|
|
72
|
+
# Everything else in this file applies regardless of which host that is.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinitecampus-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/infinitecampus-mcp",
|
|
5
5
|
"description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -32,19 +32,22 @@
|
|
|
32
32
|
".claude-plugin",
|
|
33
33
|
"skills",
|
|
34
34
|
".mcp.json",
|
|
35
|
-
"server.json"
|
|
35
|
+
"server.json",
|
|
36
|
+
"mint.yaml"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc && npm run bundle",
|
|
39
40
|
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
40
41
|
"dev": "node --env-file=.env dist/index.js",
|
|
41
|
-
"test": "vitest run",
|
|
42
|
-
"test:
|
|
42
|
+
"test": "npm run typecheck && vitest run",
|
|
43
|
+
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
44
|
+
"test:watch": "vitest",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
45
|
-
"@chrischall/mcp-utils": "^0.
|
|
46
|
-
"@fetchproxy/bootstrap": "^
|
|
47
|
-
"@fetchproxy/server": "^
|
|
48
|
+
"@chrischall/mcp-utils": "^0.18.0",
|
|
49
|
+
"@fetchproxy/bootstrap": "^2.0.0",
|
|
50
|
+
"@fetchproxy/server": "^2.0.0",
|
|
48
51
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
52
|
"dotenv": "^17.4.2",
|
|
50
53
|
"node-html-parser": "^9.0.0",
|
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": "2.
|
|
9
|
+
"version": "2.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -52,6 +52,18 @@
|
|
|
52
52
|
"description": "Set to 1 to skip the fetchproxy browser-extension fallback (missing creds become a hard error — useful in headless CI).",
|
|
53
53
|
"isRequired": false,
|
|
54
54
|
"format": "string"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "IC_SESSION_CACHE",
|
|
58
|
+
"description": "Set to false to disable the on-disk session cache and re-authenticate on every process start. Defaults to enabled.",
|
|
59
|
+
"isRequired": false,
|
|
60
|
+
"format": "string"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "IC_SESSION_FILE",
|
|
64
|
+
"description": "Absolute path for the session cache file. Defaults to $MCP_DATA_DIR/.infinitecampus-mcp/session-<district>.json.",
|
|
65
|
+
"isRequired": false,
|
|
66
|
+
"format": "string"
|
|
55
67
|
}
|
|
56
68
|
]
|
|
57
69
|
}
|