infinitecampus-mcp 2.3.4 → 2.3.6

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
@@ -1,10 +1,19 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
2
  import { dirname } from 'path';
3
3
  import { parseCookieJar } from '@chrischall/mcp-utils';
4
+ import { createCookieSessionManager } from '@chrischall/mcp-utils/session';
4
5
  const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
5
6
  export class ICClient {
6
7
  accounts = new Map();
7
- sessions = new Map();
8
+ /**
9
+ * One CookieSessionManager per district (primary + each discovered linked
10
+ * district). The manager owns the session lifecycle: single-flight login,
11
+ * `withSession` exactly-one 401 replay, `invalidate()`, permanent-error
12
+ * caching, proactive `maxAgeMs` (SESSION_TTL_MS) expiry, and — for linked
13
+ * districts — the CUPS-minted session installed via `seed()` during
14
+ * discovery.
15
+ */
16
+ managers = new Map();
8
17
  linkedTo = new Map(); // linkedDistrictName → primaryDistrictName
9
18
  primaryName;
10
19
  featuresCache = new Map();
@@ -17,6 +26,8 @@ export class ICClient {
17
26
  */
18
27
  fetchproxyMode = false;
19
28
  fetchproxyDiscoveryRan = false;
29
+ /** fetchproxy cookies, consumed by the primary manager's first login. */
30
+ preloaded = null;
20
31
  /**
21
32
  * `preloaded` is the fetchproxy escape hatch: when set, the client treats
22
33
  * the supplied cookies as a freshly-completed login on the primary account.
@@ -29,27 +40,73 @@ export class ICClient {
29
40
  constructor(account, opts = {}) {
30
41
  this.accounts.set(account.name, account);
31
42
  this.primaryName = account.name;
43
+ this.managers.set(account.name, this.makeManager(account));
32
44
  if (opts.preloaded) {
33
- this.sessions.set(account.name, {
34
- cookie: opts.preloaded.cookieHeader,
35
- xsrfToken: opts.preloaded.xsrfToken,
36
- loggedInAt: Date.now(),
37
- loginInFlight: null,
38
- });
45
+ this.preloaded = opts.preloaded;
39
46
  this.fetchproxyMode = true;
40
47
  }
41
48
  }
49
+ /**
50
+ * Build the per-district CookieSessionManager.
51
+ *
52
+ * - `maxAgeMs: SESSION_TTL_MS` makes `ensure()` proactively invalidate and
53
+ * re-login a session older than 5h (single-flight), replacing IC's old
54
+ * hand-rolled `ensureFresh` wrapper. `now` reads `Date.now` at call time so
55
+ * the clock stays mockable in tests.
56
+ * - `isPermanentError` caches the one config error the process can never
57
+ * recover from (fetchproxy no-creds) so `ensure()` fails fast instead of
58
+ * pointlessly retrying a login that can never succeed.
59
+ * - `onReplayLoginError` rethrows the re-login failure from `withSession`'s
60
+ * expiry-replay path. By default that failure is swallowed and the stale
61
+ * 401 returned; rethrowing surfaces the actionable error (e.g. fetchproxy's
62
+ * "sign back into your IC portal in the browser") to the caller instead of
63
+ * a generic SessionExpiredError — the behavior IC's `lastLoginError`
64
+ * bookkeeping used to provide.
65
+ */
66
+ makeManager(account) {
67
+ return createCookieSessionManager({
68
+ login: () => this.login(account),
69
+ isExpired: (res) => this.detectExpiredSession(account.name, res),
70
+ isPermanentError: (e) => e instanceof AuthFailedError && e.permanent,
71
+ maxAgeMs: SESSION_TTL_MS,
72
+ now: () => Date.now(),
73
+ onReplayLoginError: (err) => { throw err; },
74
+ });
75
+ }
76
+ /**
77
+ * `isExpired` predicate for `withSession`: a 401 means the session died.
78
+ * Side effect for linked districts: the shared CUPS SSO umbrella died with
79
+ * it, so drop the primary and every *sibling* linked session too — the
80
+ * linked district's re-login (via `login()`'s linked branch) then re-logs
81
+ * the primary in, whose discovery re-establishes all of them. `withSession`
82
+ * invalidates the requesting district itself. Mirrors the old hand-rolled
83
+ * doRequest 401 handling.
84
+ */
85
+ detectExpiredSession(district, res) {
86
+ if (res.status !== 401)
87
+ return false;
88
+ const primaryName = this.linkedTo.get(district);
89
+ if (primaryName) {
90
+ this.managers.get(primaryName).invalidate();
91
+ for (const linked of this.linkedTo.keys()) {
92
+ if (linked !== district)
93
+ this.managers.get(linked).invalidate();
94
+ }
95
+ }
96
+ return true;
97
+ }
42
98
  async ensureDiscovery() {
43
99
  // Ensure primary account is logged in, which triggers CUPS linked-district discovery
44
- await this.ensureSession(this.accounts.get(this.primaryName));
45
- // fetchproxy mode skips login() and therefore the discovery call inside
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). 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.
100
+ const session = await this.managers.get(this.primaryName).ensure();
101
+ // fetchproxy mode skips the verify.jsp login and therefore the discovery
102
+ // call inside it. Run discovery directly the first time someone asks for
103
+ // it. The primary is never in linkedTo (it's the root of the linkedTo
104
+ // map). When login() runs for an already-linked district during a TTL
105
+ // refresh, it re-auths through the primary instead of running discovery
106
+ // itself.
50
107
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
51
108
  this.fetchproxyDiscoveryRan = true;
52
- await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
109
+ await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
53
110
  }
54
111
  }
55
112
  listDistricts() {
@@ -86,66 +143,57 @@ export class ICClient {
86
143
  if (!account)
87
144
  throw new UnknownDistrictError(district, [...this.accounts.keys()]);
88
145
  }
89
- await this.ensureSession(account);
90
- // In fetchproxy mode, login() never runs and therefore never calls
91
- // discoverLinkedDistricts. Run discovery once on the first primary call
92
- // so linked districts are discoverable. (The primary is never in
93
- // linkedTo by construction.)
146
+ const session = await this.managers.get(account.name).ensure();
147
+ // In fetchproxy mode, the preloaded-cookie login never runs verify.jsp
148
+ // and therefore never calls discoverLinkedDistricts. Run discovery once
149
+ // on the first primary call so linked districts are discoverable. (The
150
+ // primary is never in linkedTo by construction.)
94
151
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account.name === this.primaryName) {
95
152
  this.fetchproxyDiscoveryRan = true;
96
- await this.discoverLinkedDistricts(account);
97
- }
98
- return this.doRequest(account, path, opts, false);
99
- }
100
- async ensureSession(account) {
101
- let s = this.sessions.get(account.name);
102
- if (s && Date.now() - s.loggedInAt < SESSION_TTL_MS)
103
- return;
104
- if (s?.loginInFlight) {
105
- await s.loginInFlight;
106
- return;
107
- }
108
- if (!s) {
109
- s = { cookie: '', xsrfToken: '', loggedInAt: 0, loginInFlight: null };
110
- this.sessions.set(account.name, s);
111
- }
112
- s.loginInFlight = this.login(account);
113
- try {
114
- await s.loginInFlight;
115
- }
116
- finally {
117
- s.loginInFlight = null;
153
+ await this.discoverLinkedDistricts(account, session);
118
154
  }
155
+ return this.doRequest(account, path, opts);
119
156
  }
120
157
  async login(account) {
121
158
  // Linked districts have no real credentials of their own — they hold
122
159
  // 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.
160
+ // CUPS SSO switch, not by their own verify.jsp. Whether the linked session
161
+ // expired by TTL or by 401 (detectExpiredSession dropped the primary in
162
+ // that case), re-auth through the primary: refresh it, which re-runs
163
+ // discoverLinkedDistricts and `seed()`s a fresh session into this
164
+ // district's manager — POSTing the placeholder creds to verify.jsp would
165
+ // instead yield a misleading password-error.
166
+ //
167
+ // This runs as the linked manager's `login`, which `ensure()` only invokes
168
+ // after invalidating any stale session — so `current` is `undefined` on
169
+ // entry and becomes non-undefined ONLY if the primary refresh re-ran
170
+ // discovery and seeded us. If the primary was still fresh (or discovery
171
+ // failed to restore this district), `current` stays undefined — surface a
172
+ // clear error rather than handing callers a dead session.
129
173
  const primaryName = this.linkedTo.get(account.name);
130
174
  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)) {
175
+ await this.managers.get(primaryName).ensure();
176
+ const restored = this.managers.get(account.name).current;
177
+ if (!restored) {
139
178
  throw new AuthFailedError(account.name, 'linked-district session could not be re-established via the primary district', { credentialHint: false });
140
179
  }
141
- return;
180
+ return restored;
181
+ }
182
+ // fetchproxy mode: the first primary login consumes the preloaded browser
183
+ // cookies instead of POSTing to verify.jsp. Consumed exactly once — after
184
+ // an expiry/invalidate there is nothing left to re-login with (below).
185
+ if (this.preloaded) {
186
+ const { cookieHeader, xsrfToken } = this.preloaded;
187
+ this.preloaded = null;
188
+ return { cookieHeader, xsrfToken };
142
189
  }
143
190
  // fetchproxy mode: empty primary creds, can't post to verify.jsp.
144
191
  // The user must re-sign-in in the browser (and the next process start
145
- // will pick up the fresh cookies).
192
+ // will pick up the fresh cookies). Marked permanent: the manager caches
193
+ // and rethrows this instead of retrying a login that can never succeed.
146
194
  if (!account.username || !account.password) {
147
195
  throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
148
- 'sign back into your IC portal in the browser and restart the MCP');
196
+ 'sign back into your IC portal in the browser and restart the MCP', { permanent: true });
149
197
  }
150
198
  // ic_parent_api's pattern: single POST to verify.jsp, let the response
151
199
  // set cookies. No pre-login GET needed (unlike OFW's Spring Security).
@@ -185,23 +233,21 @@ export class ICClient {
185
233
  if (!cookies.cookieHeader) {
186
234
  throw new AuthFailedError(account.name, 'login response missing session cookies');
187
235
  }
188
- // Mutate the in-map session in place so concurrent callers'
189
- // references stay live (see ensureSession).
190
- const session = this.sessions.get(account.name);
191
- session.cookie = cookies.cookieHeader;
192
- session.xsrfToken = cookies.xsrfToken;
193
- session.loggedInAt = Date.now();
236
+ const session = {
237
+ cookieHeader: cookies.cookieHeader,
238
+ xsrfToken: cookies.xsrfToken,
239
+ };
194
240
  // 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);
241
+ // thrown. By construction we only reach here for the primary: linked
242
+ // districts return early above and re-auth via the primary, so no
243
+ // `linkedTo` guard is needed.
244
+ await this.discoverLinkedDistricts(account, session);
245
+ return session;
199
246
  }
200
- async discoverLinkedDistricts(account) {
247
+ async discoverLinkedDistricts(account, session) {
201
248
  try {
202
- const session = this.sessions.get(account.name);
203
249
  const baseHeaders = {
204
- Cookie: session.cookie,
250
+ Cookie: session.cookieHeader,
205
251
  Accept: 'application/json',
206
252
  'Content-Type': 'application/json',
207
253
  Referer: `${account.baseUrl}/campus/nav-wrapper/`,
@@ -261,7 +307,12 @@ export class ICClient {
261
307
  console.error(`[ic] CUPS switch to ${linked.districtName}: no cookies`);
262
308
  continue;
263
309
  }
264
- // Store synthetic account + session
310
+ // Store synthetic account + `seed()` the freshly-minted CUPS session
311
+ // into the linked district's own manager. `seed()` installs it with a
312
+ // fresh maxAgeMs clock and DETACHES any in-flight login — so when this
313
+ // discovery is itself being awaited by a mid-flight linked `login()`
314
+ // (the re-auth-through-primary path), that login's late resolution
315
+ // won't overwrite the seed. The manager must exist before we seed it.
265
316
  const syntheticAccount = {
266
317
  name: linked.districtName,
267
318
  baseUrl: linkedBaseUrl,
@@ -270,13 +321,14 @@ export class ICClient {
270
321
  password: '(linked)',
271
322
  };
272
323
  this.accounts.set(linked.districtName, syntheticAccount);
273
- this.sessions.set(linked.districtName, {
274
- cookie: switchCookies.cookieHeader,
324
+ this.linkedTo.set(linked.districtName, account.name);
325
+ if (!this.managers.has(linked.districtName)) {
326
+ this.managers.set(linked.districtName, this.makeManager(syntheticAccount));
327
+ }
328
+ this.managers.get(linked.districtName).seed({
329
+ cookieHeader: switchCookies.cookieHeader,
275
330
  xsrfToken: switchCookies.xsrfToken,
276
- loggedInAt: Date.now(),
277
- loginInFlight: null,
278
331
  });
279
- this.linkedTo.set(linked.districtName, account.name);
280
332
  console.error(`[ic] Linked district discovered: ${linked.districtName} (${linked.appName})`);
281
333
  }
282
334
  catch (e) {
@@ -310,14 +362,13 @@ export class ICClient {
310
362
  const account = this.accounts.get(district);
311
363
  if (!account)
312
364
  throw new UnknownDistrictError(district, [...this.accounts.keys()]);
313
- await this.ensureSession(account);
314
- const session = this.sessions.get(account.name);
365
+ const session = await this.managers.get(account.name).ensure();
315
366
  // Support both relative paths (/campus/...) and absolute URLs
316
367
  // (e.g. report-card URLs from ic_list_documents come fully-qualified).
317
368
  const url = /^https?:\/\//i.test(path) ? path : `${account.baseUrl}${path}`;
318
369
  const res = await fetch(url, {
319
370
  headers: {
320
- Cookie: session.cookie,
371
+ Cookie: session.cookieHeader,
321
372
  ...(session.xsrfToken ? { 'X-XSRF-TOKEN': session.xsrfToken } : {}),
322
373
  },
323
374
  });
@@ -331,37 +382,31 @@ export class ICClient {
331
382
  contentType: res.headers.get('content-type') ?? 'application/octet-stream',
332
383
  };
333
384
  }
334
- async doRequest(account, path, opts, isRetry) {
335
- const session = this.sessions.get(account.name);
385
+ async doRequest(account, path, opts) {
386
+ const mgr = this.managers.get(account.name);
336
387
  const accept = opts.responseType === 'text' ? 'text/html, text/plain, */*' : 'application/json';
337
- const res = await fetch(`${account.baseUrl}${path}`, {
388
+ // withSession: on a 401 (detectExpiredSession, which also drops the
389
+ // primary + sibling sessions for a linked district), the manager
390
+ // invalidates this district, re-logs-in single-flight, and replays the
391
+ // request EXACTLY once.
392
+ const res = await mgr.withSession(async (session) => fetch(`${account.baseUrl}${path}`, {
338
393
  method: opts.method ?? 'GET',
339
394
  headers: {
340
- Cookie: session.cookie,
395
+ Cookie: session.cookieHeader,
341
396
  Accept: accept,
342
397
  ...(session.xsrfToken ? { 'X-XSRF-TOKEN': session.xsrfToken } : {}),
343
398
  ...(opts.headers ?? {}),
344
399
  },
345
400
  body: opts.body,
346
- });
401
+ }));
347
402
  if (res.status === 401) {
348
- if (isRetry)
349
- throw new SessionExpiredError(account.name);
350
- this.sessions.delete(account.name);
351
- const primaryName = this.linkedTo.get(account.name);
352
- if (primaryName) {
353
- // Linked district 401: invalidate primary + all linked sessions so re-login rediscovers
354
- this.sessions.delete(primaryName);
355
- for (const [linked] of this.linkedTo) {
356
- this.sessions.delete(linked);
357
- }
358
- const primaryAccount = this.accounts.get(primaryName);
359
- await this.ensureSession(primaryAccount);
360
- }
361
- else {
362
- await this.ensureSession(account);
363
- }
364
- return this.doRequest(account, path, opts, true);
403
+ // We only reach here when the re-login SUCCEEDED but the replayed request
404
+ // 401'd again (a fresh session the portal still rejects). If the re-login
405
+ // itself failed, the `onReplayLoginError` hook already rethrew that
406
+ // error out of `withSession` — carrying the actionable message (e.g.
407
+ // fetchproxy's "sign back into your IC portal") — so control never
408
+ // reaches this branch in that case.
409
+ throw new SessionExpiredError(account.name);
365
410
  }
366
411
  if (res.status >= 500)
367
412
  throw new PortalUnreachableError(account.name, res.status);
@@ -380,24 +425,17 @@ export class ICClient {
380
425
  */
381
426
  function parseSetCookies(headers) {
382
427
  // Prefer the structured getSetCookie() split (one cookie per array entry,
383
- // what real Node fetch Responses provide). Only when it's unavailable do we
384
- // fall back to naively comma-splitting the joined header — kept identical to
385
- // the original so the edge-case behavior (commas inside attribute lists) is
386
- // preserved rather than inheriting parseCookieJar's smarter-but-different
387
- // splitter. parseCookieJar then does the jar logic: drop deletion markers
428
+ // what real Node fetch Responses provide). When it's unavailable, hand the
429
+ // joined `set-cookie` string to parseCookieJar, which splits it defensively
430
+ // (safe around the commas inside `Expires` dates, unlike a naive comma
431
+ // split). parseCookieJar then does the jar logic: drop deletion markers
388
432
  // (Max-Age=0 / expired Expires) and empty values, dedupe by name. That's
389
433
  // load-bearing so the synthesized Cookie header never carries both the
390
434
  // `appName=` deletion and the real value (IC rejects "conflicting app name").
391
435
  const raw = headers.getSetCookie?.() ?? [];
392
- const entries = raw.length > 0 ? raw : splitFallback(headers.get('set-cookie'));
393
- const { cookies, cookieHeader } = parseCookieJar(entries);
436
+ const { cookies, cookieHeader } = parseCookieJar(raw.length > 0 ? raw : headers.get('set-cookie'));
394
437
  return { cookieHeader, xsrfToken: cookies['XSRF-TOKEN'] ?? '' };
395
438
  }
396
- function splitFallback(header) {
397
- if (!header)
398
- return [];
399
- return header.split(',').map((s) => s.trim());
400
- }
401
439
  export class UnknownDistrictError extends Error {
402
440
  district;
403
441
  available;
@@ -411,11 +449,19 @@ export class UnknownDistrictError extends Error {
411
449
  export class AuthFailedError extends Error {
412
450
  district;
413
451
  reason;
452
+ /**
453
+ * A *permanent* configuration failure: retrying the login inside this
454
+ * process can never succeed (e.g. fetchproxy mode with no creds — the user
455
+ * must re-sign-in in the browser and restart). CookieSessionManager caches
456
+ * and rethrows permanent errors instead of retrying.
457
+ */
458
+ permanent;
414
459
  /**
415
460
  * @param opts.credentialHint When `false`, the message omits the
416
461
  * "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
417
462
  * credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
418
463
  * failure) and pointing the user at their creds would be misleading.
464
+ * @param opts.permanent See {@link AuthFailedError.permanent}.
419
465
  */
420
466
  constructor(district, reason, opts) {
421
467
  const detail = reason ? ` (${reason})` : '';
@@ -427,6 +473,7 @@ export class AuthFailedError extends Error {
427
473
  this.district = district;
428
474
  this.reason = reason;
429
475
  this.name = 'AuthFailedError';
476
+ this.permanent = opts?.permanent ?? false;
430
477
  }
431
478
  }
432
479
  export class PortalUnreachableError extends Error {
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.4', // x-release-please-version
60
+ version: '2.3.6', // x-release-please-version
61
61
  };
62
62
  if (account) {
63
63
  const client = new ICClient(account, { preloaded });
@@ -1,20 +1,11 @@
1
1
  import { textResult } from '@chrischall/mcp-utils';
2
- /**
3
- * Wrap a value as an MCP text content block — the standard tool return shape.
4
- * Thin re-export of mcp-utils' `textResult` so the whole fleet shares one
5
- * pretty-printed-JSON formatter; the local name is kept to avoid churning
6
- * every tool call site.
7
- */
8
- export function textContent(data) {
9
- return textResult(data);
10
- }
11
2
  /** Detect "IC 404 ..." errors thrown by ICClient.request for endpoints that don't exist. */
12
3
  export function is404(e) {
13
4
  return e instanceof Error && e.message.startsWith('IC 404 ');
14
5
  }
15
6
  /** Build a FeatureDisabled warning content block. */
16
7
  export function featureDisabled(feature, district, data = []) {
17
- return textContent({ warning: 'FeatureDisabled', feature, district, data });
8
+ return textResult({ warning: 'FeatureDisabled', feature, district, data });
18
9
  }
19
10
  /** Fetch the students list and find the one matching studentId. Returns null if not found. */
20
11
  export async function findStudent(client, district, studentId) {
@@ -23,7 +14,7 @@ export async function findStudent(client, district, studentId) {
23
14
  }
24
15
  /** Standard error content block for when studentId doesn't match any student. */
25
16
  export function studentNotFound(studentId) {
26
- return textContent({ error: 'StudentNotFound', studentId });
17
+ return textResult({ error: 'StudentNotFound', studentId });
27
18
  }
28
19
  /**
29
20
  * Check a feature flag via the district's displayOptions allow-list for the
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, findStudent, studentNotFound, featureDisabled, is404, toArray, checkFeatureDisabled } from './_shared.js';
3
+ import { findStudent, studentNotFound, featureDisabled, is404, toArray, checkFeatureDisabled } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -48,6 +49,6 @@ export function registerAssessmentTools(server, client) {
48
49
  if (feature404 && result.length === 0) {
49
50
  return featureDisabled('assessments', args.district);
50
51
  }
51
- return textContent(result);
52
+ return textResult(result);
52
53
  });
53
54
  }
@@ -1,5 +1,5 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string(),
@@ -33,6 +33,6 @@ export function registerAssignmentTools(server, client) {
33
33
  if (args.missingOnly) {
34
34
  data = data.filter((a) => a.missing);
35
35
  }
36
- return textContent(data);
36
+ return textResult(data);
37
37
  });
38
38
  }
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
+ import { is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string(),
@@ -70,7 +71,7 @@ export function registerAttendanceTools(server, client) {
70
71
  results.push({ ...entry, terms: trimmedTerms });
71
72
  }
72
73
  }
73
- return textContent(results);
74
+ return textResult(results);
74
75
  }
75
76
  catch (e) {
76
77
  if (is404(e))
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
+ import { is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -78,7 +79,7 @@ export function registerAttendanceEventsTools(server, client) {
78
79
  results.push(trimmed);
79
80
  }
80
81
  }
81
- return textContent(results);
82
+ return textResult(results);
82
83
  }
83
84
  catch (e) {
84
85
  if (is404(e))
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
+ import { is404, featureDisabled, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string(),
@@ -26,7 +27,7 @@ export function registerBehaviorTools(server, client) {
26
27
  params.set('endDate', args.until);
27
28
  try {
28
29
  const data = await client.request(args.district, `/campus/resources/portal/behavior?${params}`);
29
- return textContent(data);
30
+ return textResult(data);
30
31
  }
31
32
  catch (e) {
32
33
  if (is404(e))
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, findStudent, studentNotFound, toArray } from './_shared.js';
3
+ import { findStudent, studentNotFound, toArray } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -63,6 +64,6 @@ export function registerCalendarTools(server, client) {
63
64
  terms: trimmedTerms,
64
65
  });
65
66
  }
66
- return textContent(result);
67
+ return textResult(result);
67
68
  });
68
69
  }
@@ -1,4 +1,4 @@
1
- import { textContent } from './_shared.js';
1
+ import { textResult } from '@chrischall/mcp-utils';
2
2
  export function registerDistrictTools(server, client) {
3
3
  server.registerTool('ic_list_districts', {
4
4
  description: 'List Infinite Campus districts configured for this MCP server. Returns names + base URLs (no credentials).',
@@ -6,6 +6,6 @@ export function registerDistrictTools(server, client) {
6
6
  }, async () => {
7
7
  await client.ensureDiscovery();
8
8
  const data = client.listDistricts();
9
- return textContent(data);
9
+ return textResult(data);
10
10
  });
11
11
  }
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, toArray, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
+ import { is404, featureDisabled, toArray, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
4
  const listArgs = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string(),
@@ -39,7 +40,7 @@ export function registerDocumentTools(server, client) {
39
40
  out.endYear = d.endYear;
40
41
  return out;
41
42
  });
42
- return textContent(trimmed);
43
+ return textResult(trimmed);
43
44
  }
44
45
  catch (e) {
45
46
  if (is404(e))
@@ -57,11 +58,11 @@ export function registerDocumentTools(server, client) {
57
58
  const meta = await client.download(args.district, args.documentId, args.destinationPath, {
58
59
  overwrite: args.overwrite ?? false,
59
60
  });
60
- return textContent(meta);
61
+ return textResult(meta);
61
62
  }
62
63
  catch (e) {
63
64
  if (e instanceof Error && e.message.startsWith('IC download 404')) {
64
- return textContent({ warning: 'FeatureDisabled', feature: 'documents', district: args.district });
65
+ return textResult({ warning: 'FeatureDisabled', feature: 'documents', district: args.district });
65
66
  }
66
67
  throw e;
67
68
  }
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, findStudent, studentNotFound } from './_shared.js';
3
+ import { findStudent, studentNotFound } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -24,6 +25,6 @@ export function registerFeaturesTools(server, client) {
24
25
  features,
25
26
  });
26
27
  }
27
- return textContent(result);
28
+ return textResult(result);
28
29
  });
29
30
  }
@@ -1,5 +1,6 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
1
2
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled } from './_shared.js';
3
+ import { is404, featureDisabled } from './_shared.js';
3
4
  const argsSchema = z.object({
4
5
  district: z.string(),
5
6
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -38,6 +39,6 @@ export function registerFeeTools(server, client) {
38
39
  feeAssignments: assignments.ok ? assignments.value : [],
39
40
  ...(issues.length > 0 ? { issues } : {}),
40
41
  };
41
- return textContent(response);
42
+ return textResult(response);
42
43
  });
43
44
  }