infinitecampus-mcp 2.4.4 → 2.5.0

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