infinitecampus-mcp 2.3.5 → 2.4.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/dist/bundle.js CHANGED
@@ -34704,6 +34704,9 @@ var API_KEY_RE = new RegExp([
34704
34704
  "whsec_[A-Za-z0-9]{16,}\\b"
34705
34705
  // webhook signing secret (Stripe-style)
34706
34706
  ].map((p) => `\\b${p}`).join("|"), "g");
34707
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34708
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34709
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34707
34710
 
34708
34711
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34709
34712
  function textResult(data) {
@@ -34842,6 +34845,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34842
34845
  "capture_request_header",
34843
34846
  "capture_redirect",
34844
34847
  "read_indexed_db",
34848
+ "read_dom",
34845
34849
  "download"
34846
34850
  ]);
34847
34851
 
@@ -35143,6 +35147,44 @@ function assertIndexedDbScopesArray(value, label) {
35143
35147
  }
35144
35148
  }
35145
35149
  }
35150
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35151
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35152
+ function assertDomSelectorsArray(value, label) {
35153
+ if (!Array.isArray(value)) {
35154
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35155
+ }
35156
+ const seen = /* @__PURE__ */ new Set();
35157
+ for (let i = 0; i < value.length; i++) {
35158
+ const entry = value[i];
35159
+ assertObject(entry, `${label}[${i}]`);
35160
+ if (entry.name === void 0) {
35161
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35162
+ }
35163
+ if (entry.selector === void 0) {
35164
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35165
+ }
35166
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35167
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35168
+ }
35169
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35170
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35171
+ }
35172
+ if (entry.attribute !== void 0) {
35173
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35174
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35175
+ }
35176
+ }
35177
+ if (seen.has(entry.name)) {
35178
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35179
+ }
35180
+ seen.add(entry.name);
35181
+ for (const k of Object.keys(entry)) {
35182
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35183
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35184
+ }
35185
+ }
35186
+ }
35187
+ }
35146
35188
  function validateFrame(raw) {
35147
35189
  assertObject(raw, "frame");
35148
35190
  const t = raw.type;
@@ -35218,6 +35260,9 @@ function validateHello(raw) {
35218
35260
  if (raw.sessionStoragePointers !== void 0) {
35219
35261
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35220
35262
  }
35263
+ if (raw.domSelectors !== void 0) {
35264
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35265
+ }
35221
35266
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35222
35267
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35223
35268
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35452,6 +35497,21 @@ function validateInnerRequest(raw) {
35452
35497
  }
35453
35498
  return raw;
35454
35499
  }
35500
+ if (raw.op === "read_dom") {
35501
+ assertObject(raw.init, "inner.init");
35502
+ if (raw.init.origin === void 0)
35503
+ throw new ProtocolError("inner.init.origin: missing");
35504
+ if (raw.init.names === void 0)
35505
+ throw new ProtocolError("inner.init.names: missing");
35506
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35507
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35508
+ for (const k of Object.keys(raw.init)) {
35509
+ if (k !== "origin" && k !== "names") {
35510
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35511
+ }
35512
+ }
35513
+ return raw;
35514
+ }
35455
35515
  if (raw.op === "download") {
35456
35516
  assertObject(raw.init, "inner.init");
35457
35517
  if (raw.init.url === void 0) {
@@ -35477,7 +35537,7 @@ function validateInnerRequest(raw) {
35477
35537
  }
35478
35538
  return raw;
35479
35539
  }
35480
- 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)}`);
35540
+ 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", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35481
35541
  }
35482
35542
  function assertNonEmptyKeyArray(value, label) {
35483
35543
  if (!Array.isArray(value)) {
@@ -35562,6 +35622,13 @@ function validateInnerResponse(raw) {
35562
35622
  assertObject(raw.values, "inner.values");
35563
35623
  return raw;
35564
35624
  }
35625
+ if (op === "read_dom") {
35626
+ if (raw.values === void 0) {
35627
+ throw new ProtocolError("inner.values: missing on read_dom response");
35628
+ }
35629
+ assertStringMap(raw.values, "inner.values");
35630
+ return raw;
35631
+ }
35565
35632
  if (op === "download") {
35566
35633
  assertObject(raw.value, "inner.value");
35567
35634
  assertString(raw.value.path, "inner.value.path");
@@ -35901,6 +35968,13 @@ async function buildServerHello(opts) {
35901
35968
  jsonPointer: d.jsonPointer
35902
35969
  }));
35903
35970
  }
35971
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
35972
+ hello.domSelectors = opts.domSelectors.map((d) => ({
35973
+ name: d.name,
35974
+ selector: d.selector,
35975
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
35976
+ }));
35977
+ }
35904
35978
  return hello;
35905
35979
  }
35906
35980
 
@@ -35990,7 +36064,8 @@ async function startHost(opts) {
35990
36064
  captureHeaders: opts.ownCaptureHeaders,
35991
36065
  indexedDbScopes: opts.ownIndexedDbScopes,
35992
36066
  localStoragePointers: opts.ownLocalStoragePointers,
35993
- sessionStoragePointers: opts.ownSessionStoragePointers
36067
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36068
+ domSelectors: opts.ownDomSelectors
35994
36069
  });
35995
36070
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
35996
36071
  let extensionWs = null;
@@ -36225,6 +36300,7 @@ async function startPeer(opts) {
36225
36300
  sessionStorageKeys: opts.sessionStorageKeys,
36226
36301
  captureHeaders: opts.captureHeaders,
36227
36302
  indexedDbScopes: opts.indexedDbScopes,
36303
+ domSelectors: opts.domSelectors,
36228
36304
  localStoragePointers: opts.localStoragePointers,
36229
36305
  sessionStoragePointers: opts.sessionStoragePointers
36230
36306
  });
@@ -36632,6 +36708,11 @@ var FetchproxyServer = class {
36632
36708
  key: d.key,
36633
36709
  jsonPointer: d.jsonPointer
36634
36710
  })),
36711
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36712
+ name: d.name,
36713
+ selector: d.selector,
36714
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36715
+ })),
36635
36716
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36636
36717
  // adapter was about to set these to the same numbers anyway; the
36637
36718
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36752,6 +36833,7 @@ var FetchproxyServer = class {
36752
36833
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36753
36834
  ownLocalStoragePointers: this.opts.localStoragePointers,
36754
36835
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36836
+ ownDomSelectors: this.opts.domSelectors,
36755
36837
  onPairCode: this.opts.onPairCode
36756
36838
  });
36757
36839
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36779,7 +36861,8 @@ var FetchproxyServer = class {
36779
36861
  captureHeaders: this.opts.captureHeaders,
36780
36862
  indexedDbScopes: this.opts.indexedDbScopes,
36781
36863
  localStoragePointers: this.opts.localStoragePointers,
36782
- sessionStoragePointers: this.opts.sessionStoragePointers
36864
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36865
+ domSelectors: this.opts.domSelectors
36783
36866
  });
36784
36867
  this.peerHandle.onInner((inner) => this.onInner(inner));
36785
36868
  this.peerHandle.onRenegotiate(() => {
@@ -37754,6 +37837,46 @@ var FetchproxyServer = class {
37754
37837
  await this.sendInnerFrame(inner);
37755
37838
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37756
37839
  }
37840
+ /**
37841
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37842
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37843
+ * match a declared `domSelectors` entry. The extension reads each
37844
+ * declared selector from the matched tab's DOM (isolated-world
37845
+ * `querySelector`, value or attribute) — no page-JS execution.
37846
+ *
37847
+ * Returns a `Record<string, string>` of `name → value`, with names
37848
+ * whose element (or attribute) was absent omitted. Throws
37849
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37850
+ * developer mistakes (undeclared capability, undeclared name).
37851
+ */
37852
+ async readDom(opts) {
37853
+ if (!this.opts.capabilities.includes("read_dom")) {
37854
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37855
+ }
37856
+ await this.ensureConnected();
37857
+ this.throwIfPendingPair();
37858
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37859
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37860
+ }
37861
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37862
+ if (opts.subdomain !== void 0)
37863
+ assertSubdomainLabel(opts.subdomain);
37864
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37865
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37866
+ const origin = `https://${host}`;
37867
+ const id = this.nextRequestId++;
37868
+ const inner = {
37869
+ type: "request",
37870
+ id,
37871
+ op: "read_dom",
37872
+ init: { origin, names: [...opts.names] }
37873
+ };
37874
+ const pending = new Promise((resolve, reject) => {
37875
+ this.pendingStorage.set(id, { resolve, reject });
37876
+ });
37877
+ await this.sendInnerFrame(inner);
37878
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37879
+ }
37757
37880
  assertScopeSubset(requested, declared, label) {
37758
37881
  const undeclared = undeclaredKeys(requested, declared);
37759
37882
  if (undeclared.length > 0) {
@@ -37825,7 +37948,7 @@ var FetchproxyServer = class {
37825
37948
  if (storageCb) {
37826
37949
  this.pendingStorage.delete(inner.id);
37827
37950
  if (inner.ok) {
37828
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
37951
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37829
37952
  storageCb.resolve({ ...inner.values });
37830
37953
  } else {
37831
37954
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38188,7 +38311,7 @@ function loadAccount(env = process.env) {
38188
38311
  // package.json
38189
38312
  var package_default = {
38190
38313
  name: "infinitecampus-mcp",
38191
- version: "2.3.5",
38314
+ version: "2.4.0",
38192
38315
  mcpName: "io.github.chrischall/infinitecampus-mcp",
38193
38316
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
38194
38317
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38230,19 +38353,19 @@ var package_default = {
38230
38353
  "test:watch": "vitest"
38231
38354
  },
38232
38355
  dependencies: {
38233
- "@chrischall/mcp-utils": "^0.10.3",
38356
+ "@chrischall/mcp-utils": "^0.13.0",
38234
38357
  "@fetchproxy/bootstrap": "^1.0.0",
38235
38358
  "@fetchproxy/server": "^1.0.0",
38236
38359
  "@modelcontextprotocol/sdk": "^1.29.0",
38237
38360
  dotenv: "^17.4.2",
38238
- "node-html-parser": "^8.0.2",
38361
+ "node-html-parser": "^9.0.0",
38239
38362
  zod: "^4.4.3"
38240
38363
  },
38241
38364
  devDependencies: {
38242
38365
  "@types/node": "^26.0.0",
38243
38366
  "@vitest/coverage-v8": "^4.1.7",
38244
38367
  esbuild: "^0.28.0",
38245
- typescript: "^6.0.3",
38368
+ typescript: "^7.0.2",
38246
38369
  vitest: "^4.1.7"
38247
38370
  }
38248
38371
  };
@@ -38318,10 +38441,163 @@ async function resolveAuth() {
38318
38441
  // src/client.ts
38319
38442
  import { writeFile as writeFile2, stat } from "fs/promises";
38320
38443
  import { dirname } from "path";
38444
+
38445
+ // node_modules/@chrischall/mcp-utils/dist/session/index.js
38446
+ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
38447
+ var CookieSessionManager = class {
38448
+ session;
38449
+ inFlight;
38450
+ /** A cached *permanent* config error: once set, every `ensure` rethrows it. */
38451
+ permanentError;
38452
+ /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
38453
+ sessionAt = 0;
38454
+ loginFn;
38455
+ isExpiredFn;
38456
+ isPermanentErrorFn;
38457
+ maxAgeMs;
38458
+ now;
38459
+ onReplayLoginErrorFn;
38460
+ constructor(opts) {
38461
+ this.loginFn = opts.login;
38462
+ this.isExpiredFn = opts.isExpired ?? (() => false);
38463
+ this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
38464
+ this.maxAgeMs = opts.maxAgeMs;
38465
+ this.now = opts.now ?? Date.now;
38466
+ this.onReplayLoginErrorFn = opts.onReplayLoginError;
38467
+ }
38468
+ /** The current session, or `undefined` before the first successful login. */
38469
+ get current() {
38470
+ return this.session;
38471
+ }
38472
+ /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */
38473
+ isStale() {
38474
+ return this.maxAgeMs !== void 0 && this.now() - this.sessionAt >= this.maxAgeMs;
38475
+ }
38476
+ /**
38477
+ * Return the current session, or single-flight a login if there is none.
38478
+ *
38479
+ * Concurrent callers share one in-flight login; the in-flight promise is
38480
+ * cleared on settle (success OR failure) so a rejected login never sticks and
38481
+ * the next call retries. A login error classified permanent by
38482
+ * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
38483
+ * on every later call; a transient error is not cached (next call retries).
38484
+ * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its
38485
+ * TTL is invalidated first, so the call falls through to a (single-flight)
38486
+ * re-login.
38487
+ */
38488
+ async ensure() {
38489
+ if (this.session !== void 0) {
38490
+ if (!this.isStale())
38491
+ return this.session;
38492
+ this.invalidate();
38493
+ }
38494
+ if (this.permanentError !== void 0)
38495
+ throw this.permanentError;
38496
+ if (this.inFlight === void 0) {
38497
+ this.inFlight = this.runLogin();
38498
+ }
38499
+ return this.inFlight;
38500
+ }
38501
+ /**
38502
+ * Install an externally-minted session (e.g. infinitecampus's CUPS linked-
38503
+ * district discovery, which mints sessions outside {@link
38504
+ * CookieSessionManagerOptions.login}). The seed becomes the current session
38505
+ * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any
38506
+ * in-flight login is DETACHED: its waiters still receive its result, but a
38507
+ * late resolution will not overwrite the seed. A cached permanent config
38508
+ * error is left intact — the login path is still misconfigured, and the next
38509
+ * post-seed re-login should keep saying so.
38510
+ */
38511
+ seed(session) {
38512
+ this.session = session;
38513
+ this.sessionAt = this.now();
38514
+ this.inFlight = void 0;
38515
+ }
38516
+ /**
38517
+ * One login attempt. Self-clears `inFlight` on settle so a rejected login
38518
+ * never sticks, and stamps the resulting session only while it's still the
38519
+ * live attempt (an `invalidate()` mid-flight cleared `inFlight` — a stale
38520
+ * resolution must not re-stamp the dropped session over the new state).
38521
+ */
38522
+ runLogin() {
38523
+ const holder = {};
38524
+ holder.p = (async () => {
38525
+ try {
38526
+ const session = await this.loginFn();
38527
+ if (this.inFlight === holder.p) {
38528
+ this.session = session;
38529
+ this.sessionAt = this.now();
38530
+ }
38531
+ return session;
38532
+ } catch (err) {
38533
+ if (this.isPermanentErrorFn(err))
38534
+ this.permanentError = err;
38535
+ throw err;
38536
+ } finally {
38537
+ if (this.inFlight === holder.p)
38538
+ this.inFlight = void 0;
38539
+ }
38540
+ })();
38541
+ return holder.p;
38542
+ }
38543
+ /**
38544
+ * Drop the current session (and any in-flight login) so the next
38545
+ * {@link ensure} re-runs {@link CookieSessionManagerOptions.login}. Does NOT
38546
+ * clear a cached permanent config error — that only clears on a fresh process
38547
+ * (new config). Used to recover from a detected session expiry.
38548
+ */
38549
+ invalidate() {
38550
+ this.session = void 0;
38551
+ this.inFlight = void 0;
38552
+ }
38553
+ /**
38554
+ * Run an authenticated `call` with the current session and reactive
38555
+ * expiry-replay. `call` receives the session and returns a `Response`. If
38556
+ * {@link CookieSessionManagerOptions.isExpired} flags the response, the
38557
+ * session is invalidated, a single-flight re-login runs, and `call` is
38558
+ * replayed EXACTLY once. A persistent expiry surfaces the (second) response
38559
+ * rather than looping. If the re-login itself fails, the original
38560
+ * (expired-looking) response is returned so the caller can surface a clean
38561
+ * sign-in error rather than the login failure.
38562
+ *
38563
+ * `call` resolves to `R` (the generic response type, default {@link Response});
38564
+ * the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired}
38565
+ * and returns it untouched, so a custom transport type flows through cleanly.
38566
+ */
38567
+ async withSession(call) {
38568
+ const session = await this.ensure();
38569
+ const res = await call(session);
38570
+ if (!await this.isExpiredFn(res))
38571
+ return res;
38572
+ if (this.current === session)
38573
+ this.invalidate();
38574
+ let fresh;
38575
+ try {
38576
+ fresh = await this.ensure();
38577
+ } catch (err) {
38578
+ this.onReplayLoginErrorFn?.(err);
38579
+ return res;
38580
+ }
38581
+ return call(fresh);
38582
+ }
38583
+ };
38584
+ function createCookieSessionManager(opts) {
38585
+ return new CookieSessionManager(opts);
38586
+ }
38587
+
38588
+ // src/client.ts
38321
38589
  var SESSION_TTL_MS = 5 * 60 * 60 * 1e3;
38322
38590
  var ICClient = class {
38323
38591
  accounts = /* @__PURE__ */ new Map();
38324
- sessions = /* @__PURE__ */ new Map();
38592
+ /**
38593
+ * One CookieSessionManager per district (primary + each discovered linked
38594
+ * district). The manager owns the session lifecycle: single-flight login,
38595
+ * `withSession` exactly-one 401 replay, `invalidate()`, permanent-error
38596
+ * caching, proactive `maxAgeMs` (SESSION_TTL_MS) expiry, and — for linked
38597
+ * districts — the CUPS-minted session installed via `seed()` during
38598
+ * discovery.
38599
+ */
38600
+ managers = /* @__PURE__ */ new Map();
38325
38601
  linkedTo = /* @__PURE__ */ new Map();
38326
38602
  // linkedDistrictName → primaryDistrictName
38327
38603
  primaryName;
@@ -38335,6 +38611,8 @@ var ICClient = class {
38335
38611
  */
38336
38612
  fetchproxyMode = false;
38337
38613
  fetchproxyDiscoveryRan = false;
38614
+ /** fetchproxy cookies, consumed by the primary manager's first login. */
38615
+ preloaded = null;
38338
38616
  /**
38339
38617
  * `preloaded` is the fetchproxy escape hatch: when set, the client treats
38340
38618
  * the supplied cookies as a freshly-completed login on the primary account.
@@ -38347,21 +38625,66 @@ var ICClient = class {
38347
38625
  constructor(account2, opts = {}) {
38348
38626
  this.accounts.set(account2.name, account2);
38349
38627
  this.primaryName = account2.name;
38628
+ this.managers.set(account2.name, this.makeManager(account2));
38350
38629
  if (opts.preloaded) {
38351
- this.sessions.set(account2.name, {
38352
- cookie: opts.preloaded.cookieHeader,
38353
- xsrfToken: opts.preloaded.xsrfToken,
38354
- loggedInAt: Date.now(),
38355
- loginInFlight: null
38356
- });
38630
+ this.preloaded = opts.preloaded;
38357
38631
  this.fetchproxyMode = true;
38358
38632
  }
38359
38633
  }
38634
+ /**
38635
+ * Build the per-district CookieSessionManager.
38636
+ *
38637
+ * - `maxAgeMs: SESSION_TTL_MS` makes `ensure()` proactively invalidate and
38638
+ * re-login a session older than 5h (single-flight), replacing IC's old
38639
+ * hand-rolled `ensureFresh` wrapper. `now` reads `Date.now` at call time so
38640
+ * the clock stays mockable in tests.
38641
+ * - `isPermanentError` caches the one config error the process can never
38642
+ * recover from (fetchproxy no-creds) so `ensure()` fails fast instead of
38643
+ * pointlessly retrying a login that can never succeed.
38644
+ * - `onReplayLoginError` rethrows the re-login failure from `withSession`'s
38645
+ * expiry-replay path. By default that failure is swallowed and the stale
38646
+ * 401 returned; rethrowing surfaces the actionable error (e.g. fetchproxy's
38647
+ * "sign back into your IC portal in the browser") to the caller instead of
38648
+ * a generic SessionExpiredError — the behavior IC's `lastLoginError`
38649
+ * bookkeeping used to provide.
38650
+ */
38651
+ makeManager(account2) {
38652
+ return createCookieSessionManager({
38653
+ login: () => this.login(account2),
38654
+ isExpired: (res) => this.detectExpiredSession(account2.name, res),
38655
+ isPermanentError: (e) => e instanceof AuthFailedError && e.permanent,
38656
+ maxAgeMs: SESSION_TTL_MS,
38657
+ now: () => Date.now(),
38658
+ onReplayLoginError: (err) => {
38659
+ throw err;
38660
+ }
38661
+ });
38662
+ }
38663
+ /**
38664
+ * `isExpired` predicate for `withSession`: a 401 means the session died.
38665
+ * Side effect for linked districts: the shared CUPS SSO umbrella died with
38666
+ * it, so drop the primary and every *sibling* linked session too — the
38667
+ * linked district's re-login (via `login()`'s linked branch) then re-logs
38668
+ * the primary in, whose discovery re-establishes all of them. `withSession`
38669
+ * invalidates the requesting district itself. Mirrors the old hand-rolled
38670
+ * doRequest 401 handling.
38671
+ */
38672
+ detectExpiredSession(district, res) {
38673
+ if (res.status !== 401) return false;
38674
+ const primaryName = this.linkedTo.get(district);
38675
+ if (primaryName) {
38676
+ this.managers.get(primaryName).invalidate();
38677
+ for (const linked of this.linkedTo.keys()) {
38678
+ if (linked !== district) this.managers.get(linked).invalidate();
38679
+ }
38680
+ }
38681
+ return true;
38682
+ }
38360
38683
  async ensureDiscovery() {
38361
- await this.ensureSession(this.accounts.get(this.primaryName));
38684
+ const session = await this.managers.get(this.primaryName).ensure();
38362
38685
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
38363
38686
  this.fetchproxyDiscoveryRan = true;
38364
- await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
38687
+ await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
38365
38688
  }
38366
38689
  }
38367
38690
  listDistricts() {
@@ -38394,50 +38717,37 @@ var ICClient = class {
38394
38717
  account2 = this.accounts.get(district);
38395
38718
  if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
38396
38719
  }
38397
- await this.ensureSession(account2);
38720
+ const session = await this.managers.get(account2.name).ensure();
38398
38721
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account2.name === this.primaryName) {
38399
38722
  this.fetchproxyDiscoveryRan = true;
38400
- await this.discoverLinkedDistricts(account2);
38401
- }
38402
- return this.doRequest(account2, path, opts, false);
38403
- }
38404
- async ensureSession(account2) {
38405
- let s = this.sessions.get(account2.name);
38406
- if (s && Date.now() - s.loggedInAt < SESSION_TTL_MS) return;
38407
- if (s?.loginInFlight) {
38408
- await s.loginInFlight;
38409
- return;
38410
- }
38411
- if (!s) {
38412
- s = { cookie: "", xsrfToken: "", loggedInAt: 0, loginInFlight: null };
38413
- this.sessions.set(account2.name, s);
38414
- }
38415
- s.loginInFlight = this.login(account2);
38416
- try {
38417
- await s.loginInFlight;
38418
- } finally {
38419
- s.loginInFlight = null;
38723
+ await this.discoverLinkedDistricts(account2, session);
38420
38724
  }
38725
+ return this.doRequest(account2, path, opts);
38421
38726
  }
38422
38727
  async login(account2) {
38423
38728
  const primaryName = this.linkedTo.get(account2.name);
38424
38729
  if (primaryName) {
38425
- this.sessions.delete(account2.name);
38426
- const primaryAccount = this.accounts.get(primaryName);
38427
- await this.ensureSession(primaryAccount);
38428
- if (!this.sessions.has(account2.name)) {
38730
+ await this.managers.get(primaryName).ensure();
38731
+ const restored = this.managers.get(account2.name).current;
38732
+ if (!restored) {
38429
38733
  throw new AuthFailedError(
38430
38734
  account2.name,
38431
38735
  "linked-district session could not be re-established via the primary district",
38432
38736
  { credentialHint: false }
38433
38737
  );
38434
38738
  }
38435
- return;
38739
+ return restored;
38740
+ }
38741
+ if (this.preloaded) {
38742
+ const { cookieHeader, xsrfToken } = this.preloaded;
38743
+ this.preloaded = null;
38744
+ return { cookieHeader, xsrfToken };
38436
38745
  }
38437
38746
  if (!account2.username || !account2.password) {
38438
38747
  throw new AuthFailedError(
38439
38748
  account2.name,
38440
- "session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser and restart the MCP"
38749
+ "session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser and restart the MCP",
38750
+ { permanent: true }
38441
38751
  );
38442
38752
  }
38443
38753
  const postRes = await fetch(
@@ -38470,17 +38780,17 @@ var ICClient = class {
38470
38780
  if (!cookies.cookieHeader) {
38471
38781
  throw new AuthFailedError(account2.name, "login response missing session cookies");
38472
38782
  }
38473
- const session = this.sessions.get(account2.name);
38474
- session.cookie = cookies.cookieHeader;
38475
- session.xsrfToken = cookies.xsrfToken;
38476
- session.loggedInAt = Date.now();
38477
- await this.discoverLinkedDistricts(account2);
38783
+ const session = {
38784
+ cookieHeader: cookies.cookieHeader,
38785
+ xsrfToken: cookies.xsrfToken
38786
+ };
38787
+ await this.discoverLinkedDistricts(account2, session);
38788
+ return session;
38478
38789
  }
38479
- async discoverLinkedDistricts(account2) {
38790
+ async discoverLinkedDistricts(account2, session) {
38480
38791
  try {
38481
- const session = this.sessions.get(account2.name);
38482
38792
  const baseHeaders = {
38483
- Cookie: session.cookie,
38793
+ Cookie: session.cookieHeader,
38484
38794
  Accept: "application/json",
38485
38795
  "Content-Type": "application/json",
38486
38796
  Referer: `${account2.baseUrl}/campus/nav-wrapper/`,
@@ -38548,13 +38858,14 @@ var ICClient = class {
38548
38858
  password: "(linked)"
38549
38859
  };
38550
38860
  this.accounts.set(linked.districtName, syntheticAccount);
38551
- this.sessions.set(linked.districtName, {
38552
- cookie: switchCookies.cookieHeader,
38553
- xsrfToken: switchCookies.xsrfToken,
38554
- loggedInAt: Date.now(),
38555
- loginInFlight: null
38556
- });
38557
38861
  this.linkedTo.set(linked.districtName, account2.name);
38862
+ if (!this.managers.has(linked.districtName)) {
38863
+ this.managers.set(linked.districtName, this.makeManager(syntheticAccount));
38864
+ }
38865
+ this.managers.get(linked.districtName).seed({
38866
+ cookieHeader: switchCookies.cookieHeader,
38867
+ xsrfToken: switchCookies.xsrfToken
38868
+ });
38558
38869
  console.error(`[ic] Linked district discovered: ${linked.districtName} (${linked.appName})`);
38559
38870
  } catch (e) {
38560
38871
  console.error(`[ic] CUPS flow failed for ${linked.districtName}: ${e instanceof Error ? e.message : e}`);
@@ -38580,12 +38891,11 @@ var ICClient = class {
38580
38891
  }
38581
38892
  const account2 = this.accounts.get(district);
38582
38893
  if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
38583
- await this.ensureSession(account2);
38584
- const session = this.sessions.get(account2.name);
38894
+ const session = await this.managers.get(account2.name).ensure();
38585
38895
  const url2 = /^https?:\/\//i.test(path) ? path : `${account2.baseUrl}${path}`;
38586
38896
  const res = await fetch(url2, {
38587
38897
  headers: {
38588
- Cookie: session.cookie,
38898
+ Cookie: session.cookieHeader,
38589
38899
  ...session.xsrfToken ? { "X-XSRF-TOKEN": session.xsrfToken } : {}
38590
38900
  }
38591
38901
  });
@@ -38598,34 +38908,23 @@ var ICClient = class {
38598
38908
  contentType: res.headers.get("content-type") ?? "application/octet-stream"
38599
38909
  };
38600
38910
  }
38601
- async doRequest(account2, path, opts, isRetry) {
38602
- const session = this.sessions.get(account2.name);
38911
+ async doRequest(account2, path, opts) {
38912
+ const mgr = this.managers.get(account2.name);
38603
38913
  const accept = opts.responseType === "text" ? "text/html, text/plain, */*" : "application/json";
38604
- const res = await fetch(`${account2.baseUrl}${path}`, {
38605
- method: opts.method ?? "GET",
38606
- headers: {
38607
- Cookie: session.cookie,
38608
- Accept: accept,
38609
- ...session.xsrfToken ? { "X-XSRF-TOKEN": session.xsrfToken } : {},
38610
- ...opts.headers ?? {}
38611
- },
38612
- body: opts.body
38613
- });
38914
+ const res = await mgr.withSession(
38915
+ async (session) => fetch(`${account2.baseUrl}${path}`, {
38916
+ method: opts.method ?? "GET",
38917
+ headers: {
38918
+ Cookie: session.cookieHeader,
38919
+ Accept: accept,
38920
+ ...session.xsrfToken ? { "X-XSRF-TOKEN": session.xsrfToken } : {},
38921
+ ...opts.headers ?? {}
38922
+ },
38923
+ body: opts.body
38924
+ })
38925
+ );
38614
38926
  if (res.status === 401) {
38615
- if (isRetry) throw new SessionExpiredError(account2.name);
38616
- this.sessions.delete(account2.name);
38617
- const primaryName = this.linkedTo.get(account2.name);
38618
- if (primaryName) {
38619
- this.sessions.delete(primaryName);
38620
- for (const [linked] of this.linkedTo) {
38621
- this.sessions.delete(linked);
38622
- }
38623
- const primaryAccount = this.accounts.get(primaryName);
38624
- await this.ensureSession(primaryAccount);
38625
- } else {
38626
- await this.ensureSession(account2);
38627
- }
38628
- return this.doRequest(account2, path, opts, true);
38927
+ throw new SessionExpiredError(account2.name);
38629
38928
  }
38630
38929
  if (res.status >= 500) throw new PortalUnreachableError(account2.name, res.status);
38631
38930
  if (!res.ok) throw new Error(`IC ${res.status} ${res.statusText} for ${path}`);
@@ -38638,14 +38937,11 @@ var ICClient = class {
38638
38937
  };
38639
38938
  function parseSetCookies(headers) {
38640
38939
  const raw = headers.getSetCookie?.() ?? [];
38641
- const entries = raw.length > 0 ? raw : splitFallback(headers.get("set-cookie"));
38642
- const { cookies, cookieHeader } = parseCookieJar(entries);
38940
+ const { cookies, cookieHeader } = parseCookieJar(
38941
+ raw.length > 0 ? raw : headers.get("set-cookie")
38942
+ );
38643
38943
  return { cookieHeader, xsrfToken: cookies["XSRF-TOKEN"] ?? "" };
38644
38944
  }
38645
- function splitFallback(header) {
38646
- if (!header) return [];
38647
- return header.split(",").map((s) => s.trim());
38648
- }
38649
38945
  var UnknownDistrictError = class extends Error {
38650
38946
  constructor(district, available) {
38651
38947
  super(`Unknown district '${district}'. Configured: [${available.join(", ")}]`);
@@ -38662,6 +38958,7 @@ var AuthFailedError = class extends Error {
38662
38958
  * "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
38663
38959
  * credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
38664
38960
  * failure) and pointing the user at their creds would be misleading.
38961
+ * @param opts.permanent See {@link AuthFailedError.permanent}.
38665
38962
  */
38666
38963
  constructor(district, reason, opts) {
38667
38964
  const detail = reason ? ` (${reason})` : "";
@@ -38670,9 +38967,17 @@ var AuthFailedError = class extends Error {
38670
38967
  this.district = district;
38671
38968
  this.reason = reason;
38672
38969
  this.name = "AuthFailedError";
38970
+ this.permanent = opts?.permanent ?? false;
38673
38971
  }
38674
38972
  district;
38675
38973
  reason;
38974
+ /**
38975
+ * A *permanent* configuration failure: retrying the login inside this
38976
+ * process can never succeed (e.g. fetchproxy mode with no creds — the user
38977
+ * must re-sign-in in the browser and restart). CookieSessionManager caches
38978
+ * and rethrows permanent errors instead of retrying.
38979
+ */
38980
+ permanent;
38676
38981
  };
38677
38982
  var PortalUnreachableError = class extends Error {
38678
38983
  constructor(district, status) {
@@ -38717,40 +39022,6 @@ var FileExistsError = class extends Error {
38717
39022
  path;
38718
39023
  };
38719
39024
 
38720
- // src/tools/_shared.ts
38721
- function textContent(data) {
38722
- return textResult(data);
38723
- }
38724
- function is404(e) {
38725
- return e instanceof Error && e.message.startsWith("IC 404 ");
38726
- }
38727
- function featureDisabled(feature, district, data = []) {
38728
- return textContent({ warning: "FeatureDisabled", feature, district, data });
38729
- }
38730
- async function findStudent(client, district, studentId) {
38731
- const students = await client.request(district, "/campus/api/portal/students");
38732
- return students.find((s) => String(s.personID) === studentId) ?? null;
38733
- }
38734
- function studentNotFound(studentId) {
38735
- return textContent({ error: "StudentNotFound", studentId });
38736
- }
38737
- async function checkFeatureDisabled(client, district, studentId, student, flag, toolName, emptyData = []) {
38738
- const structureID = student.enrollments?.[0]?.structureID;
38739
- if (!structureID) return null;
38740
- try {
38741
- const features = await client.getFeatures(district, structureID, studentId);
38742
- if (features[flag] === false) return featureDisabled(toolName, district, emptyData);
38743
- return null;
38744
- } catch (e) {
38745
- console.error(`[ic] displayOptions check failed for ${district}/${flag}: ${e instanceof Error ? e.message : e}`);
38746
- return null;
38747
- }
38748
- }
38749
- function toArray(value) {
38750
- if (value === null || value === void 0) return [];
38751
- return Array.isArray(value) ? value : [value];
38752
- }
38753
-
38754
39025
  // src/tools/districts.ts
38755
39026
  function registerDistrictTools(server, client) {
38756
39027
  server.registerTool("ic_list_districts", {
@@ -38759,7 +39030,7 @@ function registerDistrictTools(server, client) {
38759
39030
  }, async () => {
38760
39031
  await client.ensureDiscovery();
38761
39032
  const data = client.listDistricts();
38762
- return textContent(data);
39033
+ return textResult(data);
38763
39034
  });
38764
39035
  }
38765
39036
 
@@ -38775,7 +39046,7 @@ function registerStudentTools(server, client) {
38775
39046
  }, async (rawArgs) => {
38776
39047
  const args = argsSchema.parse(rawArgs);
38777
39048
  const data = await client.request(args.district, "/campus/api/portal/students");
38778
- return textContent(data);
39049
+ return textResult(data);
38779
39050
  });
38780
39051
  }
38781
39052
 
@@ -38795,7 +39066,7 @@ function registerScheduleTools(server, client) {
38795
39066
  const args = argsSchema2.parse(rawArgs);
38796
39067
  const params = new URLSearchParams({ personID: args.studentId });
38797
39068
  const data = await client.request(args.district, `/campus/resources/portal/roster?${params}`);
38798
- return textContent(data);
39069
+ return textResult(data);
38799
39070
  });
38800
39071
  }
38801
39072
 
@@ -38833,7 +39104,7 @@ function registerAssignmentTools(server, client) {
38833
39104
  if (args.missingOnly) {
38834
39105
  data = data.filter((a) => a.missing);
38835
39106
  }
38836
- return textContent(data);
39107
+ return textResult(data);
38837
39108
  });
38838
39109
  }
38839
39110
 
@@ -38853,10 +39124,41 @@ function registerGradeTools(server, client) {
38853
39124
  const params = new URLSearchParams({ personID: args.studentId });
38854
39125
  if (args.termId) params.set("termID", args.termId);
38855
39126
  const data = await client.request(args.district, `/campus/resources/portal/grades?${params}`);
38856
- return textContent(data);
39127
+ return textResult(data);
38857
39128
  });
38858
39129
  }
38859
39130
 
39131
+ // src/tools/_shared.ts
39132
+ function is404(e) {
39133
+ return e instanceof Error && e.message.startsWith("IC 404 ");
39134
+ }
39135
+ function featureDisabled(feature, district, data = []) {
39136
+ return textResult({ warning: "FeatureDisabled", feature, district, data });
39137
+ }
39138
+ async function findStudent(client, district, studentId) {
39139
+ const students = await client.request(district, "/campus/api/portal/students");
39140
+ return students.find((s) => String(s.personID) === studentId) ?? null;
39141
+ }
39142
+ function studentNotFound(studentId) {
39143
+ return textResult({ error: "StudentNotFound", studentId });
39144
+ }
39145
+ async function checkFeatureDisabled(client, district, studentId, student, flag, toolName, emptyData = []) {
39146
+ const structureID = student.enrollments?.[0]?.structureID;
39147
+ if (!structureID) return null;
39148
+ try {
39149
+ const features = await client.getFeatures(district, structureID, studentId);
39150
+ if (features[flag] === false) return featureDisabled(toolName, district, emptyData);
39151
+ return null;
39152
+ } catch (e) {
39153
+ console.error(`[ic] displayOptions check failed for ${district}/${flag}: ${e instanceof Error ? e.message : e}`);
39154
+ return null;
39155
+ }
39156
+ }
39157
+ function toArray(value) {
39158
+ if (value === null || value === void 0) return [];
39159
+ return Array.isArray(value) ? value : [value];
39160
+ }
39161
+
38860
39162
  // src/tools/attendance.ts
38861
39163
  var argsSchema5 = external_exports.object({
38862
39164
  district: external_exports.string(),
@@ -38921,7 +39223,7 @@ function registerAttendanceTools(server, client) {
38921
39223
  results.push({ ...entry, terms: trimmedTerms });
38922
39224
  }
38923
39225
  }
38924
- return textContent(results);
39226
+ return textResult(results);
38925
39227
  } catch (e) {
38926
39228
  if (is404(e)) return featureDisabled("attendance", args.district);
38927
39229
  throw e;
@@ -38952,7 +39254,7 @@ function registerBehaviorTools(server, client) {
38952
39254
  if (args.until) params.set("endDate", args.until);
38953
39255
  try {
38954
39256
  const data = await client.request(args.district, `/campus/resources/portal/behavior?${params}`);
38955
- return textContent(data);
39257
+ return textResult(data);
38956
39258
  } catch (e) {
38957
39259
  if (is404(e)) return featureDisabled("behavior", args.district);
38958
39260
  throw e;
@@ -38992,7 +39294,7 @@ function registerFoodServiceTools(server, client) {
38992
39294
  if (args.until) params.set("endDate", args.until);
38993
39295
  try {
38994
39296
  const data = await client.request(args.district, `/campus/resources/portal/foodService?${params}`);
38995
- return textContent(data);
39297
+ return textResult(data);
38996
39298
  } catch (e) {
38997
39299
  if (is404(e)) return featureDisabled("foodService", args.district, EMPTY_FOOD);
38998
39300
  throw e;
@@ -39012,16 +39314,50 @@ var NAMED_ENTITIES = {
39012
39314
  function extractPlainTextFromHtml(html) {
39013
39315
  if (!html)
39014
39316
  return "";
39015
- let text = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ");
39317
+ let text = stripElementContent(stripElementContent(html, "script"), "style");
39016
39318
  text = text.replace(/<[^>]+>/g, " ");
39017
- text = text.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
39018
- text = text.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)));
39319
+ text = text.replace(/&#(\d+);/g, (whole, d) => codePointOr(Number(d), whole));
39320
+ text = text.replace(/&#x([0-9a-fA-F]+);/g, (whole, h) => codePointOr(parseInt(h, 16), whole));
39019
39321
  text = text.replace(/&([a-zA-Z]+);/g, (whole, name) => {
39020
39322
  const decoded = NAMED_ENTITIES[name.toLowerCase()];
39021
39323
  return decoded ?? whole;
39022
39324
  });
39023
39325
  return text.replace(/\s+/g, " ").trim();
39024
39326
  }
39327
+ function stripElementContent(html, tag) {
39328
+ const lower = html.toLowerCase();
39329
+ const open = `<${tag}`;
39330
+ const close = `</${tag}>`;
39331
+ let out = "";
39332
+ let i = 0;
39333
+ for (; ; ) {
39334
+ const start = lower.indexOf(open, i);
39335
+ if (start < 0) {
39336
+ out += html.slice(i);
39337
+ return out;
39338
+ }
39339
+ const after = lower[start + open.length];
39340
+ if (after !== void 0 && after !== ">" && after !== " " && after !== " " && after !== "\n" && after !== "\r" && after !== "\f" && after !== "/") {
39341
+ out += html.slice(i, start + open.length);
39342
+ i = start + open.length;
39343
+ continue;
39344
+ }
39345
+ out += html.slice(i, start) + " ";
39346
+ const closeAt = lower.indexOf(close, start + open.length);
39347
+ if (closeAt < 0)
39348
+ return out;
39349
+ i = closeAt + close.length;
39350
+ }
39351
+ }
39352
+ function codePointOr(code, raw) {
39353
+ if (!Number.isInteger(code) || code < 0 || code > 1114111)
39354
+ return raw;
39355
+ try {
39356
+ return String.fromCodePoint(code);
39357
+ } catch {
39358
+ return raw;
39359
+ }
39360
+ }
39025
39361
 
39026
39362
  // src/tools/messages.ts
39027
39363
  var listArgs = external_exports.object({
@@ -39116,7 +39452,7 @@ function registerMessageTools(server, client) {
39116
39452
  return { count: 0, items: [], error: e instanceof Error ? e.message : String(e) };
39117
39453
  });
39118
39454
  const [notifications, inbox, announcements] = await Promise.all([prismPromise, inboxPromise, noticePromise]);
39119
- return textContent({ notifications, inbox, announcements });
39455
+ return textResult({ notifications, inbox, announcements });
39120
39456
  });
39121
39457
  server.registerTool("ic_get_message", {
39122
39458
  description: "Fetch the HTML body of an inbox message and return it parsed into { subject, date, body, url }. Takes a `messageUrl` which is the `url` field from an item returned by ic_list_messages' inbox section (e.g. 'portal/messageView.xsl?x=messenger.MessengerEngine-getMessageRecipientView&messageID=...'). Relative and /campus/-prefixed URLs are both accepted. Note: fetching the HTML body may mark the message as read on some district configurations; probe against an empty inbox could not confirm the side effect.",
@@ -39127,7 +39463,7 @@ function registerMessageTools(server, client) {
39127
39463
  const path = normalizeMessageUrl(args.messageUrl);
39128
39464
  const html = await client.request(args.district, path, { responseType: "text" });
39129
39465
  const parsed = parseMessageHtml(html ?? "", path);
39130
- return textContent(parsed);
39466
+ return textResult(parsed);
39131
39467
  });
39132
39468
  }
39133
39469
 
@@ -39167,7 +39503,7 @@ function registerDocumentTools(server, client) {
39167
39503
  if (d.endYear !== void 0) out.endYear = d.endYear;
39168
39504
  return out;
39169
39505
  });
39170
- return textContent(trimmed);
39506
+ return textResult(trimmed);
39171
39507
  } catch (e) {
39172
39508
  if (is404(e)) return featureDisabled("documents", args.district);
39173
39509
  throw e;
@@ -39183,10 +39519,10 @@ function registerDocumentTools(server, client) {
39183
39519
  const meta3 = await client.download(args.district, args.documentId, args.destinationPath, {
39184
39520
  overwrite: args.overwrite ?? false
39185
39521
  });
39186
- return textContent(meta3);
39522
+ return textResult(meta3);
39187
39523
  } catch (e) {
39188
39524
  if (e instanceof Error && e.message.startsWith("IC download 404")) {
39189
- return textContent({ warning: "FeatureDisabled", feature: "documents", district: args.district });
39525
+ return textResult({ warning: "FeatureDisabled", feature: "documents", district: args.district });
39190
39526
  }
39191
39527
  throw e;
39192
39528
  }
@@ -39242,7 +39578,7 @@ function registerCalendarTools(server, client) {
39242
39578
  terms: trimmedTerms
39243
39579
  });
39244
39580
  }
39245
- return textContent(result);
39581
+ return textResult(result);
39246
39582
  });
39247
39583
  }
39248
39584
 
@@ -39333,7 +39669,7 @@ function registerAttendanceEventsTools(server, client) {
39333
39669
  results.push(trimmed);
39334
39670
  }
39335
39671
  }
39336
- return textContent(results);
39672
+ return textResult(results);
39337
39673
  } catch (e) {
39338
39674
  if (is404(e)) return featureDisabled("attendance_events", args.district);
39339
39675
  throw e;
@@ -39389,7 +39725,7 @@ function registerRecentGradesTools(server, client) {
39389
39725
  }
39390
39726
  return out;
39391
39727
  });
39392
- return textContent(trimmed);
39728
+ return textResult(trimmed);
39393
39729
  });
39394
39730
  }
39395
39731
 
@@ -39444,7 +39780,7 @@ function registerTeacherTools(server, client) {
39444
39780
  const [teachersRaw, counselorsRaw] = await Promise.all([teachersPromise, counselorsPromise]);
39445
39781
  const teachers = toArray(teachersRaw).map((t) => trimRecord(t));
39446
39782
  const counselors = toArray(counselorsRaw).map((c) => trimRecord(c));
39447
- return textContent({ counselors, teachers });
39783
+ return textResult({ counselors, teachers });
39448
39784
  });
39449
39785
  }
39450
39786
 
@@ -39495,7 +39831,7 @@ function registerAssessmentTools(server, client) {
39495
39831
  if (feature404 && result.length === 0) {
39496
39832
  return featureDisabled("assessments", args.district);
39497
39833
  }
39498
- return textContent(result);
39834
+ return textResult(result);
39499
39835
  });
39500
39836
  }
39501
39837
 
@@ -39539,7 +39875,7 @@ function registerFeeTools(server, client) {
39539
39875
  feeAssignments: assignments.ok ? assignments.value : [],
39540
39876
  ...issues.length > 0 ? { issues } : {}
39541
39877
  };
39542
- return textContent(response);
39878
+ return textResult(response);
39543
39879
  });
39544
39880
  }
39545
39881
 
@@ -39567,7 +39903,7 @@ function registerFeaturesTools(server, client) {
39567
39903
  features
39568
39904
  });
39569
39905
  }
39570
- return textContent(result);
39906
+ return textResult(result);
39571
39907
  });
39572
39908
  }
39573
39909
 
@@ -39591,7 +39927,7 @@ try {
39591
39927
  }
39592
39928
  var COMMON = {
39593
39929
  name: "infinitecampus",
39594
- version: "2.3.5"
39930
+ version: "2.4.0"
39595
39931
  // x-release-please-version
39596
39932
  };
39597
39933
  if (account) {