infinitecampus-mcp 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Infinite Campus (Campus Parent) — grades, attendance, assignments, messages, documents",
10
- "version": "2.4.2"
10
+ "version": "2.4.3"
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": "2.4.2",
18
+ "version": "2.4.3",
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": "2.4.2",
4
+ "version": "2.4.3",
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/README.md CHANGED
@@ -42,7 +42,7 @@ Tools that the harness will gate as write/IO operations: `ic_download_document`.
42
42
  IC_PASSWORD=...
43
43
  IC_NAME=Springfield # optional, defaults to IC_DISTRICT
44
44
  ```
45
- 2. **fetchproxy fallback (no password needed).** Set only `IC_BASE_URL` + `IC_DISTRICT` (still required so the MCP knows which host to talk to and which district to dispatch on), then install the [fetchproxy 0.3.0 extension](https://github.com/chrischall/fetchproxy) (Chrome Web Store / Safari `.dmg`) and sign into your IC portal once. The MCP reads the `JSESSIONID` (HttpOnly) + `XSRF-TOKEN` cookies on startup and goes direct-to-API from Node thereafter — the extension is **not** in the request hot path.
45
+ 2. **fetchproxy fallback (no password needed).** Set only `IC_BASE_URL` + `IC_DISTRICT` (still required so the MCP knows which host to talk to and which district to dispatch on), then install the [fetchproxy 0.3.0 extension](https://github.com/chrischall/fetchproxy) (Chrome Web Store / Safari `.dmg`) and sign into your IC portal once. The MCP reads the `JSESSIONID` (HttpOnly) + `XSRF-TOKEN` cookies from that tab and goes direct-to-API from Node thereafter — the extension is **not** in the request hot path. The read is repeated whenever the session lapses (JSESSIONID is a servlet session with a short idle timeout), so you stay signed in without restarting the MCP.
46
46
 
47
47
  Set `IC_DISABLE_FETCHPROXY=1` to opt out of the fallback (turns missing credentials into a hard error — useful in headless CI).
48
48
 
package/dist/auth.js CHANGED
@@ -46,7 +46,7 @@
46
46
  // - `@fetchproxy/bootstrap` is mocked at the module boundary in tests.
47
47
  // - `loadAccount()` (the existing env-var resolver) is reused as-is so the
48
48
  // legacy paths keep working unchanged.
49
- import { bootstrap } from '@fetchproxy/bootstrap';
49
+ import { createSessionLifter } from '@fetchproxy/bootstrap';
50
50
  import { classifyBridgeError } from '@chrischall/mcp-utils/fetchproxy';
51
51
  import { parseBoolEnv } from '@chrischall/mcp-utils';
52
52
  import { loadAccount } from './config.js';
@@ -71,65 +71,103 @@ export async function resolveAuth() {
71
71
  return { account, source: 'env' };
72
72
  }
73
73
  // ── Path 2: fetchproxy fallback.
74
+ //
75
+ // NOTE: the browser is NOT read here. `resolveAuth()` runs once at process
76
+ // start; the lift is handed to the client as `refresh` and runs lazily on
77
+ // the first request and again on every expiry. Two bugs this avoids:
78
+ //
79
+ // * The lapsed-session dead end. JSESSIONID is a servlet session with a
80
+ // short idle timeout. A cookie captured at boot left the server
81
+ // unauthenticated the moment it lapsed, with no way back — the account
82
+ // has empty creds, so verify.jsp is not an option.
83
+ // * The sticky startup failure. A lift that failed at boot (user not yet
84
+ // signed in) was cached for the life of the process, so signing in
85
+ // afterwards changed nothing.
74
86
  if (!fetchproxyDisabled()) {
75
- try {
76
- const host = new URL(account.baseUrl).hostname;
77
- const session = await bootstrap({
78
- serverName: pkg.name,
79
- version: pkg.version,
80
- // IC tenants live on per-district hosts (campus.<district>.org,
81
- // <district>.infinitecampus.org, etc.). Declare just this host
82
- // — each district is its own root, no wildcard needed.
83
- domains: [host],
84
- declare: {
85
- // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
86
- // security gate is the declared key list). XSRF-TOKEN is
87
- // non-HttpOnly because the page JS reads it back and echoes
88
- // it on every state-changing call as the X-XSRF-TOKEN header.
89
- cookies: ['JSESSIONID', 'XSRF-TOKEN'],
90
- localStorage: [],
91
- sessionStorage: [],
92
- captureHeaders: [],
93
- },
94
- });
95
- const jsessionid = session.cookies['JSESSIONID'];
96
- const xsrf = session.cookies['XSRF-TOKEN'];
97
- if (!jsessionid) {
98
- throw new Error(`JSESSIONID cookie not found on ${host}. ` +
99
- 'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
100
- }
101
- if (!xsrf) {
102
- throw new Error(`XSRF-TOKEN cookie not found on ${host}. ` +
103
- 'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
104
- }
105
- return {
106
- account,
107
- preloaded: {
108
- cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
109
- xsrfToken: xsrf,
110
- },
111
- source: 'fetchproxy',
112
- };
113
- }
114
- catch (e) {
115
- // 0.8.0+ typed-error discrimination. The fetchproxy server already
116
- // retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
117
- // a thrown FetchproxyBridgeDownError means the retry also failed —
118
- // the extension's service worker is genuinely down and the user
119
- // needs to wake it. The `.hint` is the actionable copy
120
- // ("click the extension toolbar icon...") that we'd otherwise have
121
- // to hand-write here. Surface it verbatim so users in path 2 get
122
- // the same self-service guidance as path 3.
123
- if (classifyBridgeError(e) === 'bridge_down') {
124
- const downErr = e;
125
- throw new Error(`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
126
- }
127
- const msg = e instanceof Error ? e.message : String(e);
128
- throw new Error('IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy fallback failed: ' + msg);
129
- }
87
+ return {
88
+ account,
89
+ refresh: () => liftBrowserSession(account),
90
+ source: 'fetchproxy',
91
+ };
130
92
  }
131
93
  // ── Path 3: nothing configured and fetchproxy explicitly disabled.
132
94
  throw new Error('IC auth: set IC_USERNAME + IC_PASSWORD, ' +
133
95
  'or install the fetchproxy extension and sign into your IC portal ' +
134
96
  '(unset IC_DISABLE_FETCHPROXY if it is set).');
135
97
  }
98
+ /**
99
+ * One lifter per host, built lazily and reused.
100
+ *
101
+ * Unlike most MCPs the declared scope is not static — IC tenants live on
102
+ * per-district hosts — so the lifter cannot be a module-level constant.
103
+ * Caching by host still matters: `createSessionLifter` single-flights
104
+ * concurrent calls, and that only helps if renewals share ONE lifter instead
105
+ * of constructing a fresh one each time.
106
+ */
107
+ const liftersByHost = new Map();
108
+ function lifterFor(host) {
109
+ let lift = liftersByHost.get(host);
110
+ if (!lift) {
111
+ lift = createSessionLifter({
112
+ serverName: pkg.name,
113
+ version: pkg.version,
114
+ // IC tenants live on per-district hosts (campus.<district>.org,
115
+ // <district>.infinitecampus.org, etc.). Declare just this host
116
+ // — each district is its own root, no wildcard needed.
117
+ domains: [host],
118
+ declare: {
119
+ // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
120
+ // security gate is the declared key list). XSRF-TOKEN is
121
+ // non-HttpOnly because the page JS reads it back and echoes
122
+ // it on every state-changing call as the X-XSRF-TOKEN header.
123
+ cookies: ['JSESSIONID', 'XSRF-TOKEN'],
124
+ localStorage: [],
125
+ sessionStorage: [],
126
+ captureHeaders: [],
127
+ },
128
+ });
129
+ liftersByHost.set(host, lift);
130
+ }
131
+ return lift;
132
+ }
133
+ /**
134
+ * Lift a fresh IC session out of the user's signed-in portal tab.
135
+ *
136
+ * Runs on every login/renewal, not once at startup — fetchproxy is not in the
137
+ * request hot path, only the renewal path.
138
+ */
139
+ async function liftBrowserSession(account) {
140
+ try {
141
+ const host = new URL(account.baseUrl).hostname;
142
+ const session = await lifterFor(host)();
143
+ const jsessionid = session.cookies['JSESSIONID'];
144
+ const xsrf = session.cookies['XSRF-TOKEN'];
145
+ if (!jsessionid) {
146
+ throw new Error(`JSESSIONID cookie not found on ${host}. ` +
147
+ 'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
148
+ }
149
+ if (!xsrf) {
150
+ throw new Error(`XSRF-TOKEN cookie not found on ${host}. ` +
151
+ 'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
152
+ }
153
+ return {
154
+ cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
155
+ xsrfToken: xsrf,
156
+ };
157
+ }
158
+ catch (e) {
159
+ // 0.8.0+ typed-error discrimination. The fetchproxy server already
160
+ // retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
161
+ // a thrown FetchproxyBridgeDownError means the retry also failed —
162
+ // the extension's service worker is genuinely down and the user
163
+ // needs to wake it. The `.hint` is the actionable copy
164
+ // ("click the extension toolbar icon...") that we'd otherwise have
165
+ // to hand-write here.
166
+ if (classifyBridgeError(e) === 'bridge_down') {
167
+ const downErr = e;
168
+ throw new Error(`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
169
+ }
170
+ const msg = e instanceof Error ? e.message : String(e);
171
+ throw new Error('IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy lift failed: ' + msg);
172
+ }
173
+ }
package/dist/bundle.js CHANGED
@@ -26729,17 +26729,33 @@ function normalizeObjectSchema(schema) {
26729
26729
  }
26730
26730
  return void 0;
26731
26731
  }
26732
+ function getDotPath(path) {
26733
+ if (path.length === 0) {
26734
+ return "object root";
26735
+ }
26736
+ return path.reduce((acc, seg, index) => {
26737
+ if (index === 0) {
26738
+ return String(seg);
26739
+ }
26740
+ if (typeof seg === "number") {
26741
+ return `${acc}[${seg}]`;
26742
+ }
26743
+ return `${acc}.${seg}`;
26744
+ }, "");
26745
+ }
26732
26746
  function getParseErrorMessage(error51) {
26733
26747
  if (error51 && typeof error51 === "object") {
26748
+ if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
26749
+ return error51.issues.map((i) => {
26750
+ if (!i.path?.length) {
26751
+ return i.message;
26752
+ }
26753
+ return `${i.message} at ${getDotPath(i.path)}`;
26754
+ }).join("\n");
26755
+ }
26734
26756
  if ("message" in error51 && typeof error51.message === "string") {
26735
26757
  return error51.message;
26736
26758
  }
26737
- if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
26738
- const firstIssue = error51.issues[0];
26739
- if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
26740
- return String(firstIssue.message);
26741
- }
26742
- }
26743
26759
  try {
26744
26760
  return JSON.stringify(error51);
26745
26761
  } catch {
@@ -33354,16 +33370,7 @@ var Server = class extends Protocol {
33354
33370
  if (!methodSchema) {
33355
33371
  throw new Error("Schema is missing a method literal");
33356
33372
  }
33357
- let methodValue;
33358
- if (isZ4Schema(methodSchema)) {
33359
- const v4Schema = methodSchema;
33360
- const v4Def = v4Schema._zod?.def;
33361
- methodValue = v4Def?.value ?? v4Schema.value;
33362
- } else {
33363
- const v3Schema = methodSchema;
33364
- const legacyDef = v3Schema._def;
33365
- methodValue = legacyDef?.value ?? v3Schema.value;
33366
- }
33373
+ const methodValue = getLiteralValue(methodSchema);
33367
33374
  if (typeof methodValue !== "string") {
33368
33375
  throw new Error("Schema method literal must be a string");
33369
33376
  }
@@ -34551,8 +34558,17 @@ var EMPTY_COMPLETION_RESULT = {
34551
34558
  import process3 from "node:process";
34552
34559
 
34553
34560
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
34561
+ var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
34554
34562
  var ReadBuffer = class {
34563
+ constructor(options) {
34564
+ this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
34565
+ }
34555
34566
  append(chunk2) {
34567
+ const newSize = (this._buffer?.length ?? 0) + chunk2.length;
34568
+ if (newSize > this._maxBufferSize) {
34569
+ this.clear();
34570
+ throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
34571
+ }
34556
34572
  this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk2]) : chunk2;
34557
34573
  }
34558
34574
  readMessage() {
@@ -34580,18 +34596,24 @@ function serializeMessage(message) {
34580
34596
 
34581
34597
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
34582
34598
  var StdioServerTransport = class {
34583
- constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
34599
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
34584
34600
  this._stdin = _stdin;
34585
34601
  this._stdout = _stdout;
34586
- this._readBuffer = new ReadBuffer();
34587
34602
  this._started = false;
34588
34603
  this._ondata = (chunk2) => {
34589
- this._readBuffer.append(chunk2);
34590
- this.processReadBuffer();
34604
+ try {
34605
+ this._readBuffer.append(chunk2);
34606
+ this.processReadBuffer();
34607
+ } catch (error51) {
34608
+ this.onerror?.(error51);
34609
+ this.close().catch(() => {
34610
+ });
34611
+ }
34591
34612
  };
34592
34613
  this._onerror = (error51) => {
34593
34614
  this.onerror?.(error51);
34594
34615
  };
34616
+ this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
34595
34617
  }
34596
34618
  /**
34597
34619
  * Starts listening for messages on stdin.
@@ -38298,7 +38320,20 @@ var FetchproxyServer = class {
38298
38320
 
38299
38321
  // node_modules/@fetchproxy/bootstrap/dist/index.js
38300
38322
  var defaultFactory = (opts) => new FetchproxyServer(opts);
38301
- async function bootstrap(opts) {
38323
+ function createSessionLifter(opts) {
38324
+ let inFlight = null;
38325
+ return () => {
38326
+ if (inFlight)
38327
+ return inFlight;
38328
+ const p = runOneLift(opts).finally(() => {
38329
+ if (inFlight === p)
38330
+ inFlight = null;
38331
+ });
38332
+ inFlight = p;
38333
+ return p;
38334
+ };
38335
+ }
38336
+ async function runOneLift(opts) {
38302
38337
  const envVar = opts.serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "") + "_DISABLE_FETCHPROXY";
38303
38338
  const envVal = process.env[envVar];
38304
38339
  if (envVal !== void 0 && envVal !== "" && envVal !== "0" && envVal !== "false") {
@@ -38425,12 +38460,18 @@ async function bootstrap(opts) {
38425
38460
  });
38426
38461
  indexedDbBucket[`${d.database}/${d.store}`] = values;
38427
38462
  }
38463
+ const absent = (declared, got) => declared.filter((k) => !(k in got));
38428
38464
  return {
38429
38465
  cookies,
38430
38466
  localStorage,
38431
38467
  sessionStorage,
38432
38468
  capturedHeaders,
38433
- indexedDb: indexedDbBucket
38469
+ indexedDb: indexedDbBucket,
38470
+ missing: {
38471
+ cookies: absent(opts.declare.cookies, cookies),
38472
+ localStorage: absent(opts.declare.localStorage, localStorage),
38473
+ sessionStorage: absent(opts.declare.sessionStorage, sessionStorage)
38474
+ }
38434
38475
  };
38435
38476
  } finally {
38436
38477
  try {
@@ -38491,7 +38532,7 @@ function loadAccount(env = process.env) {
38491
38532
  // package.json
38492
38533
  var package_default = {
38493
38534
  name: "infinitecampus-mcp",
38494
- version: "2.4.2",
38535
+ version: "2.4.3",
38495
38536
  mcpName: "io.github.chrischall/infinitecampus-mcp",
38496
38537
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
38497
38538
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38534,7 +38575,7 @@ var package_default = {
38534
38575
  },
38535
38576
  dependencies: {
38536
38577
  "@chrischall/mcp-utils": "^0.14.0",
38537
- "@fetchproxy/bootstrap": "^1.7.0",
38578
+ "@fetchproxy/bootstrap": "^1.9.1",
38538
38579
  "@fetchproxy/server": "^1.7.0",
38539
38580
  "@modelcontextprotocol/sdk": "^1.29.0",
38540
38581
  dotenv: "^17.4.2",
@@ -38560,62 +38601,74 @@ async function resolveAuth() {
38560
38601
  return { account: account2, source: "env" };
38561
38602
  }
38562
38603
  if (!fetchproxyDisabled()) {
38563
- try {
38564
- const host = new URL(account2.baseUrl).hostname;
38565
- const session = await bootstrap({
38566
- serverName: package_default.name,
38567
- version: package_default.version,
38568
- // IC tenants live on per-district hosts (campus.<district>.org,
38569
- // <district>.infinitecampus.org, etc.). Declare just this host
38570
- // — each district is its own root, no wildcard needed.
38571
- domains: [host],
38572
- declare: {
38573
- // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
38574
- // security gate is the declared key list). XSRF-TOKEN is
38575
- // non-HttpOnly because the page JS reads it back and echoes
38576
- // it on every state-changing call as the X-XSRF-TOKEN header.
38577
- cookies: ["JSESSIONID", "XSRF-TOKEN"],
38578
- localStorage: [],
38579
- sessionStorage: [],
38580
- captureHeaders: []
38581
- }
38582
- });
38583
- const jsessionid = session.cookies["JSESSIONID"];
38584
- const xsrf = session.cookies["XSRF-TOKEN"];
38585
- if (!jsessionid) {
38586
- throw new Error(
38587
- `JSESSIONID cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
38588
- );
38589
- }
38590
- if (!xsrf) {
38591
- throw new Error(
38592
- `XSRF-TOKEN cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
38593
- );
38594
- }
38595
- return {
38596
- account: account2,
38597
- preloaded: {
38598
- cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
38599
- xsrfToken: xsrf
38600
- },
38601
- source: "fetchproxy"
38602
- };
38603
- } catch (e) {
38604
- if (classifyBridgeError(e) === "bridge_down") {
38605
- const downErr = e;
38606
- throw new Error(
38607
- `IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38608
- );
38604
+ return {
38605
+ account: account2,
38606
+ refresh: () => liftBrowserSession(account2),
38607
+ source: "fetchproxy"
38608
+ };
38609
+ }
38610
+ throw new Error(
38611
+ "IC auth: set IC_USERNAME + IC_PASSWORD, or install the fetchproxy extension and sign into your IC portal (unset IC_DISABLE_FETCHPROXY if it is set)."
38612
+ );
38613
+ }
38614
+ var liftersByHost = /* @__PURE__ */ new Map();
38615
+ function lifterFor(host) {
38616
+ let lift = liftersByHost.get(host);
38617
+ if (!lift) {
38618
+ lift = createSessionLifter({
38619
+ serverName: package_default.name,
38620
+ version: package_default.version,
38621
+ // IC tenants live on per-district hosts (campus.<district>.org,
38622
+ // <district>.infinitecampus.org, etc.). Declare just this host
38623
+ // — each district is its own root, no wildcard needed.
38624
+ domains: [host],
38625
+ declare: {
38626
+ // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
38627
+ // security gate is the declared key list). XSRF-TOKEN is
38628
+ // non-HttpOnly because the page JS reads it back and echoes
38629
+ // it on every state-changing call as the X-XSRF-TOKEN header.
38630
+ cookies: ["JSESSIONID", "XSRF-TOKEN"],
38631
+ localStorage: [],
38632
+ sessionStorage: [],
38633
+ captureHeaders: []
38609
38634
  }
38610
- const msg = e instanceof Error ? e.message : String(e);
38635
+ });
38636
+ liftersByHost.set(host, lift);
38637
+ }
38638
+ return lift;
38639
+ }
38640
+ async function liftBrowserSession(account2) {
38641
+ try {
38642
+ const host = new URL(account2.baseUrl).hostname;
38643
+ const session = await lifterFor(host)();
38644
+ const jsessionid = session.cookies["JSESSIONID"];
38645
+ const xsrf = session.cookies["XSRF-TOKEN"];
38646
+ if (!jsessionid) {
38647
+ throw new Error(
38648
+ `JSESSIONID cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
38649
+ );
38650
+ }
38651
+ if (!xsrf) {
38652
+ throw new Error(
38653
+ `XSRF-TOKEN cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
38654
+ );
38655
+ }
38656
+ return {
38657
+ cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
38658
+ xsrfToken: xsrf
38659
+ };
38660
+ } catch (e) {
38661
+ if (classifyBridgeError(e) === "bridge_down") {
38662
+ const downErr = e;
38611
38663
  throw new Error(
38612
- "IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy fallback failed: " + msg
38664
+ `IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38613
38665
  );
38614
38666
  }
38667
+ const msg = e instanceof Error ? e.message : String(e);
38668
+ throw new Error(
38669
+ "IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy lift failed: " + msg
38670
+ );
38615
38671
  }
38616
- throw new Error(
38617
- "IC auth: set IC_USERNAME + IC_PASSWORD, or install the fetchproxy extension and sign into your IC portal (unset IC_DISABLE_FETCHPROXY if it is set)."
38618
- );
38619
38672
  }
38620
38673
 
38621
38674
  // src/client.ts
@@ -38790,24 +38843,29 @@ var ICClient = class {
38790
38843
  * first call to `ensureDiscovery()`.
38791
38844
  */
38792
38845
  fetchproxyMode = false;
38793
- fetchproxyDiscoveryRan = false;
38794
- /** fetchproxy cookies, consumed by the primary manager's first login. */
38795
- preloaded = null;
38796
- /**
38797
- * `preloaded` is the fetchproxy escape hatch: when set, the client treats
38798
- * the supplied cookies as a freshly-completed login on the primary account.
38799
- * This skips the `verify.jsp` POST entirely (the account has empty creds
38800
- * in this mode) but otherwise behaves identically — CUPS linked-district
38801
- * discovery still runs on first request, 401 retry still triggers a
38802
- * re-login. On a 401 with empty credentials we can't re-login from Node;
38803
- * the user must re-sign-in in the browser.
38846
+ /**
38847
+ * fetchproxy lift. Called by the primary manager's login — on the first
38848
+ * request AND on every renewal, which is what keeps the path alive past
38849
+ * JSESSIONID's idle timeout.
38850
+ */
38851
+ refreshSession = null;
38852
+ /**
38853
+ * `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
38854
+ * the `verify.jsp` POST as the way this client mints a primary session.
38855
+ * It is called lazily on the first request and again on every expiry, so a
38856
+ * lapsed servlet session recovers by re-reading the browser rather than
38857
+ * dead-ending. CUPS linked-district discovery still runs on first request.
38858
+ *
38859
+ * It must re-read the browser each time rather than return a captured
38860
+ * value: JSESSIONID lapses on a short idle timer and the account has no
38861
+ * credentials to fall back on.
38804
38862
  */
38805
38863
  constructor(account2, opts = {}) {
38806
38864
  this.accounts.set(account2.name, account2);
38807
38865
  this.primaryName = account2.name;
38808
38866
  this.managers.set(account2.name, this.makeManager(account2));
38809
- if (opts.preloaded) {
38810
- this.preloaded = opts.preloaded;
38867
+ if (opts.refreshSession) {
38868
+ this.refreshSession = opts.refreshSession;
38811
38869
  this.fetchproxyMode = true;
38812
38870
  }
38813
38871
  }
@@ -38862,10 +38920,7 @@ var ICClient = class {
38862
38920
  }
38863
38921
  async ensureDiscovery() {
38864
38922
  const session = await this.managers.get(this.primaryName).ensure();
38865
- if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
38866
- this.fetchproxyDiscoveryRan = true;
38867
- await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
38868
- }
38923
+ void session;
38869
38924
  }
38870
38925
  listDistricts() {
38871
38926
  return [...this.accounts.values()].map((a) => ({
@@ -38897,11 +38952,7 @@ var ICClient = class {
38897
38952
  account2 = this.accounts.get(district);
38898
38953
  if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
38899
38954
  }
38900
- const session = await this.managers.get(account2.name).ensure();
38901
- if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account2.name === this.primaryName) {
38902
- this.fetchproxyDiscoveryRan = true;
38903
- await this.discoverLinkedDistricts(account2, session);
38904
- }
38955
+ await this.managers.get(account2.name).ensure();
38905
38956
  return this.doRequest(account2, path, opts);
38906
38957
  }
38907
38958
  async login(account2) {
@@ -38918,15 +38969,15 @@ var ICClient = class {
38918
38969
  }
38919
38970
  return restored;
38920
38971
  }
38921
- if (this.preloaded) {
38922
- const { cookieHeader, xsrfToken } = this.preloaded;
38923
- this.preloaded = null;
38924
- return { cookieHeader, xsrfToken };
38972
+ if (this.refreshSession) {
38973
+ const session2 = await this.refreshSession();
38974
+ await this.discoverLinkedDistricts(account2, session2);
38975
+ return session2;
38925
38976
  }
38926
38977
  if (!account2.username || !account2.password) {
38927
38978
  throw new AuthFailedError(
38928
38979
  account2.name,
38929
- "session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser and restart the MCP",
38980
+ "session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser, or set IC_USERNAME/IC_PASSWORD",
38930
38981
  { permanent: true }
38931
38982
  );
38932
38983
  }
@@ -40094,24 +40145,24 @@ await loadDotenvSafely({
40094
40145
  });
40095
40146
  var AI_NOTICE = "[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.";
40096
40147
  var account = null;
40097
- var preloaded;
40148
+ var refreshSession;
40098
40149
  var source;
40099
40150
  var configError = null;
40100
40151
  try {
40101
40152
  const resolved = await resolveAuth();
40102
40153
  account = resolved.account;
40103
- preloaded = resolved.preloaded;
40154
+ refreshSession = resolved.refresh;
40104
40155
  source = resolved.source;
40105
40156
  } catch (e) {
40106
40157
  configError = e;
40107
40158
  }
40108
40159
  var COMMON = {
40109
40160
  name: "infinitecampus",
40110
- version: "2.4.2"
40161
+ version: "2.4.3"
40111
40162
  // x-release-please-version
40112
40163
  };
40113
40164
  if (account) {
40114
- const client = new ICClient(account, { preloaded });
40165
+ const client = new ICClient(account, { refreshSession });
40115
40166
  const tools = [
40116
40167
  registerDistrictTools,
40117
40168
  registerStudentTools,
package/dist/client.js CHANGED
@@ -25,24 +25,29 @@ export class ICClient {
25
25
  * first call to `ensureDiscovery()`.
26
26
  */
27
27
  fetchproxyMode = false;
28
- fetchproxyDiscoveryRan = false;
29
- /** fetchproxy cookies, consumed by the primary manager's first login. */
30
- preloaded = null;
31
28
  /**
32
- * `preloaded` is the fetchproxy escape hatch: when set, the client treats
33
- * the supplied cookies as a freshly-completed login on the primary account.
34
- * This skips the `verify.jsp` POST entirely (the account has empty creds
35
- * in this mode) but otherwise behaves identically — CUPS linked-district
36
- * discovery still runs on first request, 401 retry still triggers a
37
- * re-login. On a 401 with empty credentials we can't re-login from Node;
38
- * the user must re-sign-in in the browser.
29
+ * fetchproxy lift. Called by the primary manager's login — on the first
30
+ * request AND on every renewal, which is what keeps the path alive past
31
+ * JSESSIONID's idle timeout.
32
+ */
33
+ refreshSession = null;
34
+ /**
35
+ * `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
36
+ * the `verify.jsp` POST as the way this client mints a primary session.
37
+ * It is called lazily on the first request and again on every expiry, so a
38
+ * lapsed servlet session recovers by re-reading the browser rather than
39
+ * dead-ending. CUPS linked-district discovery still runs on first request.
40
+ *
41
+ * It must re-read the browser each time rather than return a captured
42
+ * value: JSESSIONID lapses on a short idle timer and the account has no
43
+ * credentials to fall back on.
39
44
  */
40
45
  constructor(account, opts = {}) {
41
46
  this.accounts.set(account.name, account);
42
47
  this.primaryName = account.name;
43
48
  this.managers.set(account.name, this.makeManager(account));
44
- if (opts.preloaded) {
45
- this.preloaded = opts.preloaded;
49
+ if (opts.refreshSession) {
50
+ this.refreshSession = opts.refreshSession;
46
51
  this.fetchproxyMode = true;
47
52
  }
48
53
  }
@@ -98,16 +103,11 @@ export class ICClient {
98
103
  async ensureDiscovery() {
99
104
  // Ensure primary account is logged in, which triggers CUPS linked-district discovery
100
105
  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.
107
- if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
108
- this.fetchproxyDiscoveryRan = true;
109
- await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
110
- }
106
+ // Nothing further to do: `ensure()` mints the primary session when one is
107
+ // needed, and every primary mint runs discovery — in fetchproxy mode via
108
+ // login()'s lift branch, in env mode via mintSessionCookie(). A cached
109
+ // primary session means discovery already ran for it.
110
+ void session;
111
111
  }
112
112
  listDistricts() {
113
113
  return [...this.accounts.values()].map((a) => ({
@@ -143,15 +143,7 @@ export class ICClient {
143
143
  if (!account)
144
144
  throw new UnknownDistrictError(district, [...this.accounts.keys()]);
145
145
  }
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.)
151
- if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account.name === this.primaryName) {
152
- this.fetchproxyDiscoveryRan = true;
153
- await this.discoverLinkedDistricts(account, session);
154
- }
146
+ await this.managers.get(account.name).ensure();
155
147
  return this.doRequest(account, path, opts);
156
148
  }
157
149
  async login(account) {
@@ -179,13 +171,20 @@ export class ICClient {
179
171
  }
180
172
  return restored;
181
173
  }
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 };
174
+ // fetchproxy mode: mint the primary session by re-reading the browser
175
+ // instead of POSTing to verify.jsp. This runs on EVERY login, not just
176
+ // the first — a lapsed JSESSIONID recovers by lifting the live session
177
+ // the browser still holds, which is the whole point.
178
+ if (this.refreshSession) {
179
+ const session = await this.refreshSession();
180
+ // Mirror mintSessionCookie(): a primary re-mint re-runs CUPS discovery
181
+ // so linked districts get re-seeded. Without this a linked district
182
+ // could never recover — its login() defers to the primary and then
183
+ // reads `current`, which would stay unset because the primary's mint
184
+ // short-circuited past discovery. By construction we only reach here
185
+ // for the primary (linked districts return early above).
186
+ await this.discoverLinkedDistricts(account, session);
187
+ return session;
189
188
  }
190
189
  // fetchproxy mode: empty primary creds, can't post to verify.jsp.
191
190
  // The user must re-sign-in in the browser (and the next process start
@@ -193,7 +192,7 @@ export class ICClient {
193
192
  // and rethrows this instead of retrying a login that can never succeed.
194
193
  if (!account.username || !account.password) {
195
194
  throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
196
- 'sign back into your IC portal in the browser and restart the MCP', { permanent: true });
195
+ 'sign back into your IC portal in the browser, or set IC_USERNAME/IC_PASSWORD', { permanent: true });
197
196
  }
198
197
  // ic_parent_api's pattern: single POST to verify.jsp, let the response
199
198
  // set cookies. No pre-login GET needed (unlike OFW's Spring Security).
package/dist/index.js CHANGED
@@ -37,13 +37,13 @@ const AI_NOTICE = '[infinitecampus-mcp] Developed and maintained by AI (Claude).
37
37
  // IC portal in the browser). When not configured we register no tools and
38
38
  // log a clear stderr message — far better than the previous crash loop.
39
39
  let account = null;
40
- let preloaded;
40
+ let refreshSession;
41
41
  let source;
42
42
  let configError = null;
43
43
  try {
44
44
  const resolved = await resolveAuth();
45
45
  account = resolved.account;
46
- preloaded = resolved.preloaded;
46
+ refreshSession = resolved.refresh;
47
47
  source = resolved.source;
48
48
  }
49
49
  catch (e) {
@@ -57,10 +57,10 @@ catch (e) {
57
57
  // actionable stderr message (banner) instead of a crash loop.
58
58
  const COMMON = {
59
59
  name: 'infinitecampus',
60
- version: '2.4.2', // x-release-please-version
60
+ version: '2.4.3', // x-release-please-version
61
61
  };
62
62
  if (account) {
63
- const client = new ICClient(account, { preloaded });
63
+ const client = new ICClient(account, { refreshSession });
64
64
  const tools = [
65
65
  registerDistrictTools,
66
66
  registerStudentTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
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>",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@chrischall/mcp-utils": "^0.14.0",
46
- "@fetchproxy/bootstrap": "^1.7.0",
46
+ "@fetchproxy/bootstrap": "^1.9.1",
47
47
  "@fetchproxy/server": "^1.7.0",
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "dotenv": "^17.4.2",
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.4.2",
9
+ "version": "2.4.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.4.2",
14
+ "version": "2.4.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },