infinitecampus-mcp 2.3.2 → 2.3.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.3.2"
10
+ "version": "2.3.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.3.2",
18
+ "version": "2.3.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.3.2",
4
+ "version": "2.3.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/dist/bundle.js CHANGED
@@ -34791,7 +34791,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34791
34791
  "read_local_storage",
34792
34792
  "read_session_storage",
34793
34793
  "capture_request_header",
34794
- "read_indexed_db"
34794
+ "capture_redirect",
34795
+ "read_indexed_db",
34796
+ "download"
34795
34797
  ]);
34796
34798
 
34797
34799
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -35351,6 +35353,31 @@ function validateInnerRequest(raw) {
35351
35353
  }
35352
35354
  return raw;
35353
35355
  }
35356
+ if (raw.op === "capture_redirect") {
35357
+ assertObject(raw.init, "inner.init");
35358
+ if (raw.init.host === void 0) {
35359
+ throw new ProtocolError("inner.init.host: missing");
35360
+ }
35361
+ assertString(raw.init.host, "inner.init.host");
35362
+ if (!HOSTNAME_RE.test(raw.init.host)) {
35363
+ throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
35364
+ }
35365
+ if (raw.init.path !== void 0) {
35366
+ assertString(raw.init.path, "inner.init.path");
35367
+ if (!CAPTURE_PATH_RE.test(raw.init.path)) {
35368
+ throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
35369
+ }
35370
+ }
35371
+ if (raw.init.timeoutMs !== void 0) {
35372
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35373
+ }
35374
+ for (const k of Object.keys(raw.init)) {
35375
+ if (k !== "host" && k !== "path" && k !== "timeoutMs") {
35376
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_redirect`);
35377
+ }
35378
+ }
35379
+ return raw;
35380
+ }
35354
35381
  if (raw.op === "read_indexed_db") {
35355
35382
  assertObject(raw.init, "inner.init");
35356
35383
  if (raw.init.origin === void 0)
@@ -35376,7 +35403,32 @@ function validateInnerRequest(raw) {
35376
35403
  }
35377
35404
  return raw;
35378
35405
  }
35379
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "read_indexed_db"; got ${JSON.stringify(raw.op)}`);
35406
+ if (raw.op === "download") {
35407
+ assertObject(raw.init, "inner.init");
35408
+ if (raw.init.url === void 0) {
35409
+ throw new ProtocolError("inner.init.url: missing");
35410
+ }
35411
+ assertHttpUrl(raw.init.url, "inner.init.url");
35412
+ if (raw.init.filename !== void 0) {
35413
+ assertString(raw.init.filename, "inner.init.filename");
35414
+ if (/^([/\\]|[A-Za-z]:)/.test(raw.init.filename)) {
35415
+ throw new ProtocolError(`inner.init.filename: must be relative ${JSON.stringify(raw.init.filename)}`);
35416
+ }
35417
+ if (raw.init.filename.split(/[/\\]/).includes("..")) {
35418
+ throw new ProtocolError(`inner.init.filename: must not contain '..' ${JSON.stringify(raw.init.filename)}`);
35419
+ }
35420
+ }
35421
+ if (raw.init.timeoutMs !== void 0) {
35422
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35423
+ }
35424
+ for (const k of Object.keys(raw.init)) {
35425
+ if (k !== "url" && k !== "filename" && k !== "timeoutMs") {
35426
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on download`);
35427
+ }
35428
+ }
35429
+ return raw;
35430
+ }
35431
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35380
35432
  }
35381
35433
  function assertNonEmptyKeyArray(value, label) {
35382
35434
  if (!Array.isArray(value)) {
@@ -35447,6 +35499,13 @@ function validateInnerResponse(raw) {
35447
35499
  assertString(raw.value, "inner.value");
35448
35500
  return raw;
35449
35501
  }
35502
+ if (op === "capture_redirect") {
35503
+ if (raw.value === void 0) {
35504
+ throw new ProtocolError("inner.value: missing on capture_redirect response");
35505
+ }
35506
+ assertString(raw.value, "inner.value");
35507
+ return raw;
35508
+ }
35450
35509
  if (op === "read_indexed_db") {
35451
35510
  if (raw.values === void 0) {
35452
35511
  throw new ProtocolError("inner.values: missing on read_indexed_db response");
@@ -35454,6 +35513,25 @@ function validateInnerResponse(raw) {
35454
35513
  assertObject(raw.values, "inner.values");
35455
35514
  return raw;
35456
35515
  }
35516
+ if (op === "download") {
35517
+ assertObject(raw.value, "inner.value");
35518
+ assertString(raw.value.path, "inner.value.path");
35519
+ if (typeof raw.value.bytes !== "number" || !Number.isInteger(raw.value.bytes) || raw.value.bytes < 0) {
35520
+ throw new ProtocolError("inner.value.bytes: expected non-negative integer");
35521
+ }
35522
+ if (raw.value.mime !== void 0) {
35523
+ assertString(raw.value.mime, "inner.value.mime");
35524
+ }
35525
+ if (raw.value.finalUrl !== void 0) {
35526
+ assertString(raw.value.finalUrl, "inner.value.finalUrl");
35527
+ }
35528
+ for (const k of Object.keys(raw.value)) {
35529
+ if (k !== "path" && k !== "bytes" && k !== "mime" && k !== "finalUrl") {
35530
+ throw new ProtocolError(`inner.value: unexpected field ${JSON.stringify(k)} on download response`);
35531
+ }
35532
+ }
35533
+ return raw;
35534
+ }
35457
35535
  throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
35458
35536
  }
35459
35537
  if (raw.ok === false) {
@@ -36324,8 +36402,12 @@ var FetchproxyServer = class {
36324
36402
  pendingStorage = /* @__PURE__ */ new Map();
36325
36403
  // 0.3.0+: capture-header awaiters resolve a single string.
36326
36404
  pendingCapture = /* @__PURE__ */ new Map();
36405
+ // capture_redirect awaiters resolve the captured redirect URL string.
36406
+ pendingRedirect = /* @__PURE__ */ new Map();
36327
36407
  // 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
36328
36408
  pendingIdb = /* @__PURE__ */ new Map();
36409
+ // download awaiters resolve the saved-file metadata (path + size + mime).
36410
+ pendingDownload = /* @__PURE__ */ new Map();
36329
36411
  mcpId = null;
36330
36412
  identity = null;
36331
36413
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -37257,6 +37339,181 @@ var FetchproxyServer = class {
37257
37339
  }
37258
37340
  return pending;
37259
37341
  }
37342
+ /**
37343
+ * Snapshot the redirect target URL of the next request the browser
37344
+ * makes to `(host, path?)`. Single-shot: the extension registers a
37345
+ * one-time `chrome.webRequest.onBeforeRedirect` listener filtered on
37346
+ * `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on
37347
+ * the first match, removes itself, and resolves with the URL. Times out
37348
+ * after `timeoutMs` (default 30s on the extension).
37349
+ *
37350
+ * Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin
37351
+ * to a presigned URL — a page-level fetch sees only an opaque redirect,
37352
+ * but `onBeforeRedirect` exposes the target. Capture is limited to the
37353
+ * MCP's own declared `domains`; no per-entry declared scope is required.
37354
+ */
37355
+ async captureRedirect(opts) {
37356
+ if (!this.opts.capabilities.includes("capture_redirect")) {
37357
+ throw new Error('FetchproxyServer.captureRedirect(): MCP did not declare "capture_redirect" in capabilities');
37358
+ }
37359
+ await this.ensureConnected();
37360
+ this.throwIfPendingPair();
37361
+ try {
37362
+ const result = await this._captureRedirectOnce(opts);
37363
+ this.recordSuccess();
37364
+ return result;
37365
+ } catch (err) {
37366
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37367
+ if (!swDown) {
37368
+ this.recordFailure(`capture_redirect: ${err.message ?? String(err)}`);
37369
+ throw err;
37370
+ }
37371
+ this.lastEvictionDetectedAt = Date.now();
37372
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37373
+ if (reviveMs > 0) {
37374
+ this.lazyReviveAttempts += 1;
37375
+ await new Promise((r) => setTimeout(r, reviveMs));
37376
+ try {
37377
+ const result = await this._captureRedirectOnce(opts);
37378
+ this.lazyReviveSuccesses += 1;
37379
+ this.recordSuccess();
37380
+ return result;
37381
+ } catch (retryErr) {
37382
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37383
+ if (!stillDown) {
37384
+ this.recordFailure(`capture_redirect: ${retryErr.message ?? String(retryErr)}`);
37385
+ throw retryErr;
37386
+ }
37387
+ this.recordFailure(`capture_redirect bridge-down: ${retryErr.message}`);
37388
+ throw new FetchproxyBridgeDownError({
37389
+ originalError: retryErr.message,
37390
+ retryAttempted: true,
37391
+ op: "capture_redirect",
37392
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37393
+ role: this.role,
37394
+ port: this.opts.port
37395
+ });
37396
+ }
37397
+ }
37398
+ this.recordFailure(`capture_redirect bridge-down: ${err.message}`);
37399
+ throw new FetchproxyBridgeDownError({
37400
+ originalError: err.message,
37401
+ retryAttempted: false,
37402
+ op: "capture_redirect",
37403
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37404
+ role: this.role,
37405
+ port: this.opts.port
37406
+ });
37407
+ }
37408
+ }
37409
+ async _captureRedirectOnce(opts) {
37410
+ const id = this.nextRequestId++;
37411
+ const inner = {
37412
+ type: "request",
37413
+ id,
37414
+ op: "capture_redirect",
37415
+ init: {
37416
+ host: opts.host,
37417
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37418
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37419
+ }
37420
+ };
37421
+ const pending = new Promise((resolve, reject) => {
37422
+ this.pendingRedirect.set(id, { resolve, reject });
37423
+ });
37424
+ if (this.hostHandle) {
37425
+ await this.hostHandle.sendOwnInner(inner);
37426
+ } else if (this.peerHandle) {
37427
+ await this.peerHandle.sendInner(inner);
37428
+ }
37429
+ return pending;
37430
+ }
37431
+ /**
37432
+ * Download `url` through the BROWSER's own network stack via
37433
+ * `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a
37434
+ * page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge
37435
+ * on the endpoint and follows the cross-origin redirect to the final file.
37436
+ * Resolves the saved local file path + size; the bridge is loopback-only /
37437
+ * single-host, so the MCP reads the bytes from the same disk. Requires
37438
+ * `'download'` in capabilities and the `url` host to be a declared `domain`.
37439
+ */
37440
+ async download(opts) {
37441
+ if (!this.opts.capabilities.includes("download")) {
37442
+ throw new Error('FetchproxyServer.download(): MCP did not declare "download" in capabilities');
37443
+ }
37444
+ assertUrlInDomains("download url", opts.url, this.opts.domains);
37445
+ await this.ensureConnected();
37446
+ this.throwIfPendingPair();
37447
+ try {
37448
+ const result = await this._downloadOnce(opts);
37449
+ this.recordSuccess();
37450
+ return result;
37451
+ } catch (err) {
37452
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37453
+ if (!swDown) {
37454
+ this.recordFailure(`download: ${err.message ?? String(err)}`);
37455
+ throw err;
37456
+ }
37457
+ this.lastEvictionDetectedAt = Date.now();
37458
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37459
+ if (reviveMs > 0) {
37460
+ this.lazyReviveAttempts += 1;
37461
+ await new Promise((r) => setTimeout(r, reviveMs));
37462
+ try {
37463
+ const result = await this._downloadOnce(opts);
37464
+ this.lazyReviveSuccesses += 1;
37465
+ this.recordSuccess();
37466
+ return result;
37467
+ } catch (retryErr) {
37468
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37469
+ if (!stillDown) {
37470
+ this.recordFailure(`download: ${retryErr.message ?? String(retryErr)}`);
37471
+ throw retryErr;
37472
+ }
37473
+ this.recordFailure(`download bridge-down: ${retryErr.message}`);
37474
+ throw new FetchproxyBridgeDownError({
37475
+ originalError: retryErr.message,
37476
+ retryAttempted: true,
37477
+ op: "download",
37478
+ url: opts.url,
37479
+ role: this.role,
37480
+ port: this.opts.port
37481
+ });
37482
+ }
37483
+ }
37484
+ this.recordFailure(`download bridge-down: ${err.message}`);
37485
+ throw new FetchproxyBridgeDownError({
37486
+ originalError: err.message,
37487
+ retryAttempted: false,
37488
+ op: "download",
37489
+ url: opts.url,
37490
+ role: this.role,
37491
+ port: this.opts.port
37492
+ });
37493
+ }
37494
+ }
37495
+ async _downloadOnce(opts) {
37496
+ const id = this.nextRequestId++;
37497
+ const inner = {
37498
+ type: "request",
37499
+ id,
37500
+ op: "download",
37501
+ init: {
37502
+ url: opts.url,
37503
+ ...opts.filename !== void 0 ? { filename: opts.filename } : {},
37504
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37505
+ }
37506
+ };
37507
+ const pending = new Promise((resolve, reject) => {
37508
+ this.pendingDownload.set(id, { resolve, reject });
37509
+ });
37510
+ if (this.hostHandle) {
37511
+ await this.hostHandle.sendOwnInner(inner);
37512
+ } else if (this.peerHandle) {
37513
+ await this.peerHandle.sendInner(inner);
37514
+ }
37515
+ return pending;
37516
+ }
37260
37517
  /**
37261
37518
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
37262
37519
  * tab. Requires `'read_indexed_db'` in capabilities AND the
@@ -37404,6 +37661,20 @@ var FetchproxyServer = class {
37404
37661
  }
37405
37662
  return;
37406
37663
  }
37664
+ const redirectCb = this.pendingRedirect.get(inner.id);
37665
+ if (redirectCb) {
37666
+ this.pendingRedirect.delete(inner.id);
37667
+ if (inner.ok) {
37668
+ if (inner.op === "capture_redirect" && typeof inner.value === "string") {
37669
+ redirectCb.resolve(inner.value);
37670
+ } else {
37671
+ redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
37672
+ }
37673
+ } else {
37674
+ redirectCb.reject(new FetchproxyProtocolError(inner.error));
37675
+ }
37676
+ return;
37677
+ }
37407
37678
  const idbCb = this.pendingIdb.get(inner.id);
37408
37679
  if (idbCb) {
37409
37680
  this.pendingIdb.delete(inner.id);
@@ -37418,6 +37689,20 @@ var FetchproxyServer = class {
37418
37689
  }
37419
37690
  return;
37420
37691
  }
37692
+ const downloadCb = this.pendingDownload.get(inner.id);
37693
+ if (downloadCb) {
37694
+ this.pendingDownload.delete(inner.id);
37695
+ if (inner.ok) {
37696
+ if (inner.op === "download" && inner.value && typeof inner.value === "object") {
37697
+ downloadCb.resolve({ ...inner.value });
37698
+ } else {
37699
+ downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
37700
+ }
37701
+ } else {
37702
+ downloadCb.reject(new FetchproxyProtocolError(inner.error));
37703
+ }
37704
+ return;
37705
+ }
37421
37706
  const cookiesCb = this.pendingReadCookies.get(inner.id);
37422
37707
  if (cookiesCb) {
37423
37708
  this.pendingReadCookies.delete(inner.id);
@@ -37460,9 +37745,15 @@ var FetchproxyServer = class {
37460
37745
  for (const { reject } of this.pendingCapture.values())
37461
37746
  reject(err);
37462
37747
  this.pendingCapture.clear();
37748
+ for (const { reject } of this.pendingRedirect.values())
37749
+ reject(err);
37750
+ this.pendingRedirect.clear();
37463
37751
  for (const { reject } of this.pendingIdb.values())
37464
37752
  reject(err);
37465
37753
  this.pendingIdb.clear();
37754
+ for (const { reject } of this.pendingDownload.values())
37755
+ reject(err);
37756
+ this.pendingDownload.clear();
37466
37757
  }
37467
37758
  /**
37468
37759
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -37709,7 +38000,7 @@ function loadAccount(env = process.env) {
37709
38000
  // package.json
37710
38001
  var package_default = {
37711
38002
  name: "infinitecampus-mcp",
37712
- version: "2.3.2",
38003
+ version: "2.3.3",
37713
38004
  mcpName: "io.github.chrischall/infinitecampus-mcp",
37714
38005
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
37715
38006
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37941,6 +38232,20 @@ var ICClient = class {
37941
38232
  }
37942
38233
  }
37943
38234
  async login(account2) {
38235
+ const primaryName = this.linkedTo.get(account2.name);
38236
+ if (primaryName) {
38237
+ this.sessions.delete(account2.name);
38238
+ const primaryAccount = this.accounts.get(primaryName);
38239
+ await this.ensureSession(primaryAccount);
38240
+ if (!this.sessions.has(account2.name)) {
38241
+ throw new AuthFailedError(
38242
+ account2.name,
38243
+ "linked-district session could not be re-established via the primary district",
38244
+ { credentialHint: false }
38245
+ );
38246
+ }
38247
+ return;
38248
+ }
37944
38249
  if (!account2.username || !account2.password) {
37945
38250
  throw new AuthFailedError(
37946
38251
  account2.name,
@@ -37948,8 +38253,17 @@ var ICClient = class {
37948
38253
  );
37949
38254
  }
37950
38255
  const postRes = await fetch(
37951
- `${account2.baseUrl}/campus/verify.jsp?nonBrowser=true&username=${encodeURIComponent(account2.username)}&password=${encodeURIComponent(account2.password)}&appName=${encodeURIComponent(account2.district)}&portalLoginPage=parents`,
37952
- { method: "POST" }
38256
+ `${account2.baseUrl}/campus/verify.jsp?nonBrowser=true`,
38257
+ {
38258
+ method: "POST",
38259
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
38260
+ body: new URLSearchParams({
38261
+ username: account2.username,
38262
+ password: account2.password,
38263
+ appName: account2.district,
38264
+ portalLoginPage: "parents"
38265
+ }).toString()
38266
+ }
37953
38267
  );
37954
38268
  if (postRes.status >= 500) throw new PortalUnreachableError(account2.name, postRes.status);
37955
38269
  const body = await postRes.text();
@@ -37972,9 +38286,7 @@ var ICClient = class {
37972
38286
  session.cookie = cookies.cookieHeader;
37973
38287
  session.xsrfToken = cookies.xsrfToken;
37974
38288
  session.loggedInAt = Date.now();
37975
- if (!this.linkedTo.has(account2.name)) {
37976
- await this.discoverLinkedDistricts(account2);
37977
- }
38289
+ await this.discoverLinkedDistricts(account2);
37978
38290
  }
37979
38291
  async discoverLinkedDistricts(account2) {
37980
38292
  try {
@@ -38157,11 +38469,16 @@ var UnknownDistrictError = class extends Error {
38157
38469
  available;
38158
38470
  };
38159
38471
  var AuthFailedError = class extends Error {
38160
- constructor(district, reason) {
38472
+ /**
38473
+ * @param opts.credentialHint When `false`, the message omits the
38474
+ * "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
38475
+ * credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
38476
+ * failure) and pointing the user at their creds would be misleading.
38477
+ */
38478
+ constructor(district, reason, opts) {
38161
38479
  const detail = reason ? ` (${reason})` : "";
38162
- super(
38163
- `Login failed for district '${district}'${detail}. Check IC_USERNAME and IC_PASSWORD; if those are correct, the account may be locked or the portal may be down.`
38164
- );
38480
+ const remedy = opts?.credentialHint === false ? "Sign in again at the IC portal in your browser, then restart the MCP." : "Check IC_USERNAME and IC_PASSWORD; if those are correct, the account may be locked or the portal may be down.";
38481
+ super(`Login failed for district '${district}'${detail}. ${remedy}`);
38165
38482
  this.district = district;
38166
38483
  this.reason = reason;
38167
38484
  this.name = "AuthFailedError";
@@ -39086,7 +39403,7 @@ try {
39086
39403
  }
39087
39404
  var COMMON = {
39088
39405
  name: "infinitecampus",
39089
- version: "2.3.2"
39406
+ version: "2.3.3"
39090
39407
  // x-release-please-version
39091
39408
  };
39092
39409
  if (account) {
package/dist/client.js CHANGED
@@ -44,10 +44,9 @@ export class ICClient {
44
44
  await this.ensureSession(this.accounts.get(this.primaryName));
45
45
  // fetchproxy mode skips login() and therefore the discovery call inside
46
46
  // it. Run discovery directly the first time someone asks for it. The
47
- // primary is never in linkedTo (it's the root of the linkedTo map), so
48
- // no need to guard on that the existing guard inside login() is for
49
- // the inverse case where login() is called for an already-linked
50
- // district during a TTL refresh.
47
+ // primary is never in linkedTo (it's the root of the linkedTo map). When
48
+ // login() is called for an already-linked district during a TTL refresh,
49
+ // it re-auths through the primary instead of running discovery itself.
51
50
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
52
51
  this.fetchproxyDiscoveryRan = true;
53
52
  await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
@@ -119,18 +118,50 @@ export class ICClient {
119
118
  }
120
119
  }
121
120
  async login(account) {
121
+ // Linked districts have no real credentials of their own — they hold
122
+ // synthetic '(linked)' placeholders and are authenticated by the primary's
123
+ // CUPS SSO switch, not by their own verify.jsp. When a linked-district
124
+ // session expires by TTL (vs. a 401, which doRequest already routes through
125
+ // the primary), ensureSession lands here. Re-login the primary, which
126
+ // re-runs discoverLinkedDistricts and re-establishes this district's
127
+ // session — POSTing the placeholder creds to verify.jsp would instead yield
128
+ // a misleading password-error. See the comment block at the top of the file.
129
+ const primaryName = this.linkedTo.get(account.name);
130
+ if (primaryName) {
131
+ // Drop the stale linked session so re-discovery installs a fresh one.
132
+ this.sessions.delete(account.name);
133
+ const primaryAccount = this.accounts.get(primaryName);
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)) {
139
+ throw new AuthFailedError(account.name, 'linked-district session could not be re-established via the primary district', { credentialHint: false });
140
+ }
141
+ return;
142
+ }
122
143
  // fetchproxy mode: empty primary creds, can't post to verify.jsp.
123
144
  // The user must re-sign-in in the browser (and the next process start
124
- // will pick up the fresh cookies). Linked accounts have placeholder
125
- // creds too; their re-auth flows through the primary via CUPS, not
126
- // verify.jsp directly.
145
+ // will pick up the fresh cookies).
127
146
  if (!account.username || !account.password) {
128
147
  throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
129
148
  'sign back into your IC portal in the browser and restart the MCP');
130
149
  }
131
150
  // ic_parent_api's pattern: single POST to verify.jsp, let the response
132
151
  // set cookies. No pre-login GET needed (unlike OFW's Spring Security).
133
- const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true&username=${encodeURIComponent(account.username)}&password=${encodeURIComponent(account.password)}&appName=${encodeURIComponent(account.district)}&portalLoginPage=parents`, { method: 'POST' });
152
+ // Credentials go in the urlencoded form body, NOT the URL query string —
153
+ // query-string creds land in proxy/LB/server access logs even over HTTPS.
154
+ // Mirrors the CUPS switch POST body construction against the same host.
155
+ const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158
+ body: new URLSearchParams({
159
+ username: account.username,
160
+ password: account.password,
161
+ appName: account.district,
162
+ portalLoginPage: 'parents',
163
+ }).toString(),
164
+ });
134
165
  if (postRes.status >= 500)
135
166
  throw new PortalUnreachableError(account.name, postRes.status);
136
167
  // IC's verify.jsp returns 200 with an <AUTHENTICATION>X</AUTHENTICATION>
@@ -160,10 +191,11 @@ export class ICClient {
160
191
  session.cookie = cookies.cookieHeader;
161
192
  session.xsrfToken = cookies.xsrfToken;
162
193
  session.loggedInAt = Date.now();
163
- // Discover linked districts (CUPS SSO) — non-blocking, errors logged not thrown
164
- if (!this.linkedTo.has(account.name)) {
165
- await this.discoverLinkedDistricts(account);
166
- }
194
+ // Discover linked districts (CUPS SSO) — non-blocking, errors logged not
195
+ // thrown. By construction we only reach here for the primary (or fetchproxy
196
+ // primary): linked districts return early above and re-auth via the primary,
197
+ // so no `linkedTo` guard is needed.
198
+ await this.discoverLinkedDistricts(account);
167
199
  }
168
200
  async discoverLinkedDistricts(account) {
169
201
  try {
@@ -379,11 +411,19 @@ export class UnknownDistrictError extends Error {
379
411
  export class AuthFailedError extends Error {
380
412
  district;
381
413
  reason;
382
- constructor(district, reason) {
414
+ /**
415
+ * @param opts.credentialHint When `false`, the message omits the
416
+ * "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
417
+ * credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
418
+ * failure) and pointing the user at their creds would be misleading.
419
+ */
420
+ constructor(district, reason, opts) {
383
421
  const detail = reason ? ` (${reason})` : '';
384
- super(`Login failed for district '${district}'${detail}. ` +
385
- `Check IC_USERNAME and IC_PASSWORD; ` +
386
- `if those are correct, the account may be locked or the portal may be down.`);
422
+ const remedy = opts?.credentialHint === false
423
+ ? 'Sign in again at the IC portal in your browser, then restart the MCP.'
424
+ : 'Check IC_USERNAME and IC_PASSWORD; ' +
425
+ 'if those are correct, the account may be locked or the portal may be down.';
426
+ super(`Login failed for district '${district}'${detail}. ${remedy}`);
387
427
  this.district = district;
388
428
  this.reason = reason;
389
429
  this.name = 'AuthFailedError';
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.2', // x-release-please-version
60
+ version: '2.3.3', // x-release-please-version
61
61
  };
62
62
  if (account) {
63
63
  const client = new ICClient(account, { preloaded });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.3.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>",
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.3.2",
9
+ "version": "2.3.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.3.2",
14
+ "version": "2.3.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },