infinitecampus-mcp 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
2
  import { dirname } from 'path';
3
+ import { parseCookieJar } from '@chrischall/mcp-utils';
3
4
  const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
4
5
  export class ICClient {
5
6
  accounts = new Map();
@@ -43,10 +44,9 @@ export class ICClient {
43
44
  await this.ensureSession(this.accounts.get(this.primaryName));
44
45
  // fetchproxy mode skips login() and therefore the discovery call inside
45
46
  // it. Run discovery directly the first time someone asks for it. The
46
- // primary is never in linkedTo (it's the root of the linkedTo map), so
47
- // no need to guard on that the existing guard inside login() is for
48
- // the inverse case where login() is called for an already-linked
49
- // district during a TTL refresh.
47
+ // primary is never in linkedTo (it's the root of the linkedTo map). When
48
+ // login() is called for an already-linked district during a TTL refresh,
49
+ // it re-auths through the primary instead of running discovery itself.
50
50
  if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
51
51
  this.fetchproxyDiscoveryRan = true;
52
52
  await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
@@ -118,18 +118,50 @@ export class ICClient {
118
118
  }
119
119
  }
120
120
  async login(account) {
121
+ // Linked districts have no real credentials of their own — they hold
122
+ // synthetic '(linked)' placeholders and are authenticated by the primary's
123
+ // CUPS SSO switch, not by their own verify.jsp. When a linked-district
124
+ // session expires by TTL (vs. a 401, which doRequest already routes through
125
+ // the primary), ensureSession lands here. Re-login the primary, which
126
+ // re-runs discoverLinkedDistricts and re-establishes this district's
127
+ // session — POSTing the placeholder creds to verify.jsp would instead yield
128
+ // a misleading password-error. See the comment block at the top of the file.
129
+ const primaryName = this.linkedTo.get(account.name);
130
+ if (primaryName) {
131
+ // Drop the stale linked session so re-discovery installs a fresh one.
132
+ this.sessions.delete(account.name);
133
+ const primaryAccount = this.accounts.get(primaryName);
134
+ await this.ensureSession(primaryAccount);
135
+ // ensureSession(primary) re-ran login() → discoverLinkedDistricts(),
136
+ // which re-creates the linked session. If discovery failed to restore it,
137
+ // surface a clear error rather than leaving callers with a null session.
138
+ if (!this.sessions.has(account.name)) {
139
+ throw new AuthFailedError(account.name, 'linked-district session could not be re-established via the primary district', { credentialHint: false });
140
+ }
141
+ return;
142
+ }
121
143
  // fetchproxy mode: empty primary creds, can't post to verify.jsp.
122
144
  // The user must re-sign-in in the browser (and the next process start
123
- // will pick up the fresh cookies). Linked accounts have placeholder
124
- // creds too; their re-auth flows through the primary via CUPS, not
125
- // verify.jsp directly.
145
+ // will pick up the fresh cookies).
126
146
  if (!account.username || !account.password) {
127
147
  throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
128
148
  'sign back into your IC portal in the browser and restart the MCP');
129
149
  }
130
150
  // ic_parent_api's pattern: single POST to verify.jsp, let the response
131
151
  // set cookies. No pre-login GET needed (unlike OFW's Spring Security).
132
- const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true&username=${encodeURIComponent(account.username)}&password=${encodeURIComponent(account.password)}&appName=${encodeURIComponent(account.district)}&portalLoginPage=parents`, { method: 'POST' });
152
+ // Credentials go in the urlencoded form body, NOT the URL query string —
153
+ // query-string creds land in proxy/LB/server access logs even over HTTPS.
154
+ // Mirrors the CUPS switch POST body construction against the same host.
155
+ const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158
+ body: new URLSearchParams({
159
+ username: account.username,
160
+ password: account.password,
161
+ appName: account.district,
162
+ portalLoginPage: 'parents',
163
+ }).toString(),
164
+ });
133
165
  if (postRes.status >= 500)
134
166
  throw new PortalUnreachableError(account.name, postRes.status);
135
167
  // IC's verify.jsp returns 200 with an <AUTHENTICATION>X</AUTHENTICATION>
@@ -159,10 +191,11 @@ export class ICClient {
159
191
  session.cookie = cookies.cookieHeader;
160
192
  session.xsrfToken = cookies.xsrfToken;
161
193
  session.loggedInAt = Date.now();
162
- // Discover linked districts (CUPS SSO) — non-blocking, errors logged not thrown
163
- if (!this.linkedTo.has(account.name)) {
164
- await this.discoverLinkedDistricts(account);
165
- }
194
+ // Discover linked districts (CUPS SSO) — non-blocking, errors logged not
195
+ // thrown. By construction we only reach here for the primary (or fetchproxy
196
+ // primary): linked districts return early above and re-auth via the primary,
197
+ // so no `linkedTo` guard is needed.
198
+ await this.discoverLinkedDistricts(account);
166
199
  }
167
200
  async discoverLinkedDistricts(account) {
168
201
  try {
@@ -342,39 +375,23 @@ export class ICClient {
342
375
  }
343
376
  }
344
377
  /**
345
- * Parse Set-Cookie headers into a deduplicated cookie string + XSRF token.
346
- *
347
- * IC's login response sets ~20 cookies including deletion markers (Max-Age=0).
348
- * Sending both `appName=` (delete) and `appName=springfield` (set) causes IC to
349
- * reject requests with "conflicting app name values". This parser:
350
- * - Filters out cookies with Max-Age=0 (deletion markers)
351
- * - Deduplicates by name (last value wins)
352
- * - Extracts XSRF-TOKEN separately for the X-XSRF-TOKEN request header
378
+ * Parse Set-Cookie headers into a deduplicated Cookie header + XSRF token via
379
+ * `parseCookieJar` (see inline comment for the splitter/jar details).
353
380
  */
354
381
  function parseSetCookies(headers) {
382
+ // 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
388
+ // (Max-Age=0 / expired Expires) and empty values, dedupe by name. That's
389
+ // load-bearing so the synthesized Cookie header never carries both the
390
+ // `appName=` deletion and the real value (IC rejects "conflicting app name").
355
391
  const raw = headers.getSetCookie?.() ?? [];
356
- const headerStrings = raw.length > 0 ? raw : splitFallback(headers.get('set-cookie'));
357
- const jar = new Map();
358
- let xsrfToken = '';
359
- for (const entry of headerStrings) {
360
- // Check for Max-Age=0 → this is a cookie deletion, skip it
361
- if (/Max-Age=0/i.test(entry))
362
- continue;
363
- const nameValue = entry.split(';')[0].trim();
364
- const eqIdx = nameValue.indexOf('=');
365
- if (eqIdx < 1)
366
- continue;
367
- const name = nameValue.substring(0, eqIdx);
368
- const value = nameValue.substring(eqIdx + 1);
369
- // Skip cookies with empty values (clearing instructions)
370
- if (!value)
371
- continue;
372
- jar.set(name, value);
373
- if (name === 'XSRF-TOKEN')
374
- xsrfToken = value;
375
- }
376
- const cookieHeader = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
377
- return { cookieHeader, xsrfToken };
392
+ const entries = raw.length > 0 ? raw : splitFallback(headers.get('set-cookie'));
393
+ const { cookies, cookieHeader } = parseCookieJar(entries);
394
+ return { cookieHeader, xsrfToken: cookies['XSRF-TOKEN'] ?? '' };
378
395
  }
379
396
  function splitFallback(header) {
380
397
  if (!header)
@@ -394,11 +411,19 @@ export class UnknownDistrictError extends Error {
394
411
  export class AuthFailedError extends Error {
395
412
  district;
396
413
  reason;
397
- constructor(district, reason) {
414
+ /**
415
+ * @param opts.credentialHint When `false`, the message omits the
416
+ * "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
417
+ * credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
418
+ * failure) and pointing the user at their creds would be misleading.
419
+ */
420
+ constructor(district, reason, opts) {
398
421
  const detail = reason ? ` (${reason})` : '';
399
- super(`Login failed for district '${district}'${detail}. ` +
400
- `Check IC_USERNAME and IC_PASSWORD; ` +
401
- `if those are correct, the account may be locked or the portal may be down.`);
422
+ const remedy = opts?.credentialHint === false
423
+ ? 'Sign in again at the IC portal in your browser, then restart the MCP.'
424
+ : 'Check IC_USERNAME and IC_PASSWORD; ' +
425
+ 'if those are correct, the account may be locked or the portal may be down.';
426
+ super(`Login failed for district '${district}'${detail}. ${remedy}`);
402
427
  this.district = district;
403
428
  this.reason = reason;
404
429
  this.name = 'AuthFailedError';
package/dist/config.js CHANGED
@@ -1,20 +1,14 @@
1
+ import { readEnvVar } from '@chrischall/mcp-utils';
1
2
  /**
2
3
  * Read an env var, trim whitespace, and treat as unset if blank or if the value
3
4
  * looks like an unsubstituted shell placeholder (e.g. `${FOO}`) — defends
4
5
  * against MCP hosts that pass .mcp.json env blocks through unexpanded.
6
+ *
7
+ * Thin adapter over mcp-utils' `readEnvVar` (same defensive semantics), keeping
8
+ * the local `(env, key)` arg order so the call sites below stay unchanged.
5
9
  */
6
10
  function readVar(env, key) {
7
- const raw = env[key];
8
- if (typeof raw !== 'string')
9
- return undefined;
10
- const trimmed = raw.trim();
11
- if (trimmed.length === 0)
12
- return undefined;
13
- if (trimmed === 'undefined' || trimmed === 'null')
14
- return undefined;
15
- if (/^\$\{[^}]*\}$/.test(trimmed))
16
- return undefined;
17
- return trimmed;
11
+ return readEnvVar(key, { env });
18
12
  }
19
13
  /**
20
14
  * Load the Account from env vars. IC_BASE_URL + IC_DISTRICT are ALWAYS
package/dist/index.js CHANGED
@@ -1,18 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname, join } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- try {
5
- const { config } = await import('dotenv');
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- // quiet:true suppresses dotenv's startup banner required because MCP uses
8
- // stdout for JSON-RPC and any extra output corrupts the stream.
9
- config({ path: join(__dirname, '..', '.env'), override: false, quiet: true });
10
- }
11
- catch {
12
- // dotenv not available — rely on process.env
13
- }
14
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { loadDotenvSafely, runMcp } from '@chrischall/mcp-utils';
5
+ // quiet load of the local .env (no-throw, silent when the file is absent —
6
+ // e.g. inside an mcpb bundle). Path is resolved next to dist/ so the same
7
+ // `..`/.env that the previous inline dotenv call used still applies.
8
+ // override:false keeps real host-provided env winning. stdout stays clean
9
+ // for JSON-RPC (loadDotenvSafely forces dotenv's quiet:true).
10
+ await loadDotenvSafely({
11
+ path: join(dirname(fileURLToPath(import.meta.url)), '..', '.env'),
12
+ override: false,
13
+ });
16
14
  import { resolveAuth } from './auth.js';
17
15
  import { ICClient } from './client.js';
18
16
  import { registerDistrictTools } from './tools/districts.js';
@@ -32,6 +30,7 @@ import { registerTeacherTools } from './tools/teachers.js';
32
30
  import { registerAssessmentTools } from './tools/assessments.js';
33
31
  import { registerFeeTools } from './tools/fees.js';
34
32
  import { registerFeaturesTools } from './tools/features.js';
33
+ const AI_NOTICE = '[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.';
35
34
  // Defer config errors so the server can still start cleanly when env vars
36
35
  // aren't set (e.g. during the host's install-time smoke test, before the
37
36
  // user has filled in user_config OR the user hasn't yet signed into their
@@ -50,33 +49,43 @@ try {
50
49
  catch (e) {
51
50
  configError = e;
52
51
  }
53
- const server = new McpServer({ name: 'infinitecampus', version: '2.3.1' }); // x-release-please-version
52
+ // Hand off to mcp-utils' runMcp for the connect + stdio transport + graceful
53
+ // shutdown. We keep the deferred-config-error pattern by deciding the registrar
54
+ // list and banner up here: when configured we wire every
55
+ // register<Domain>Tools(server, client) call; when not, we register no tools so
56
+ // the host's install-time tools/list still succeeds and the user gets an
57
+ // actionable stderr message (banner) instead of a crash loop.
58
+ const COMMON = {
59
+ name: 'infinitecampus',
60
+ version: '2.3.3', // x-release-please-version
61
+ };
54
62
  if (account) {
55
63
  const client = new ICClient(account, { preloaded });
56
- registerDistrictTools(server, client);
57
- registerStudentTools(server, client);
58
- registerScheduleTools(server, client);
59
- registerAssignmentTools(server, client);
60
- registerGradeTools(server, client);
61
- registerAttendanceTools(server, client);
62
- registerBehaviorTools(server, client);
63
- registerFoodServiceTools(server, client);
64
- registerMessageTools(server, client);
65
- registerDocumentTools(server, client);
66
- registerCalendarTools(server, client);
67
- registerAttendanceEventsTools(server, client);
68
- registerRecentGradesTools(server, client);
69
- registerTeacherTools(server, client);
70
- registerAssessmentTools(server, client);
71
- registerFeeTools(server, client);
72
- registerFeaturesTools(server, client);
64
+ const tools = [
65
+ registerDistrictTools,
66
+ registerStudentTools,
67
+ registerScheduleTools,
68
+ registerAssignmentTools,
69
+ registerGradeTools,
70
+ registerAttendanceTools,
71
+ registerBehaviorTools,
72
+ registerFoodServiceTools,
73
+ registerMessageTools,
74
+ registerDocumentTools,
75
+ registerCalendarTools,
76
+ registerAttendanceEventsTools,
77
+ registerRecentGradesTools,
78
+ registerTeacherTools,
79
+ registerAssessmentTools,
80
+ registerFeeTools,
81
+ registerFeaturesTools,
82
+ ];
73
83
  const suffix = source === 'fetchproxy' ? ' [via fetchproxy]' : '';
74
- console.error(`[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`);
84
+ const districtLine = `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`;
85
+ await runMcp({ ...COMMON, deps: client, tools, banner: `${districtLine}\n${AI_NOTICE}` });
75
86
  }
76
87
  else {
77
- console.error(`[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}`);
78
- console.error('[infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.');
88
+ const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}\n` +
89
+ '[infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.';
90
+ await runMcp({ ...COMMON, tools: [], banner: `${notConfigured}\n${AI_NOTICE}` });
79
91
  }
80
- console.error('[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
81
- const transport = new StdioServerTransport();
82
- await server.connect(transport);
@@ -1,6 +1,12 @@
1
- /** Wrap a value as an MCP text content block — the standard tool return shape. */
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
+ */
2
8
  export function textContent(data) {
3
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
9
+ return textResult(data);
4
10
  }
5
11
  /** Detect "IC 404 ..." errors thrown by ICClient.request for endpoints that don't exist. */
6
12
  export function is404(e) {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { extractPlainTextFromHtml } from '@chrischall/mcp-utils/html';
2
3
  import { textContent, toArray } from './_shared.js';
3
4
  const listArgs = z.object({
4
5
  district: z.string(),
@@ -44,29 +45,18 @@ export function normalizeMessageUrl(input) {
44
45
  * The HTML has:
45
46
  * <title>Message -- <subject></title>
46
47
  * body with "Message: <subject>", "Date: MM/DD/YYYY", then the message body.
47
- * Dependency-free: no DOM parser, just regex + tag stripping.
48
+ * Dependency-free: title via regex, body via mcp-utils'
49
+ * `extractPlainTextFromHtml` (script/style strip + tag strip + entity decode +
50
+ * whitespace collapse — the shared fleet helper this extractor was hoisted from).
48
51
  */
49
52
  export function parseMessageHtml(html, url) {
50
53
  // Extract <title>
51
54
  const titleMatch = html.match(/<title>([^<]*)<\/title>/i);
52
55
  let subject = titleMatch ? titleMatch[1].trim() : '';
53
56
  subject = subject.replace(/^Message\s*--\s*/i, '');
54
- // Strip <script> and <style> blocks first
55
- let text = html
56
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
57
- .replace(/<style[\s\S]*?<\/style>/gi, ' ');
58
- // Strip remaining tags
59
- text = text.replace(/<[^>]+>/g, ' ');
60
- // Decode a few common entities
61
- text = text
62
- .replace(/&nbsp;/g, ' ')
63
- .replace(/&amp;/g, '&')
64
- .replace(/&lt;/g, '<')
65
- .replace(/&gt;/g, '>')
66
- .replace(/&quot;/g, '"')
67
- .replace(/&#39;/g, "'");
68
- // Collapse whitespace
69
- text = text.replace(/\s+/g, ' ').trim();
57
+ // Strip the body down to plain text (script/style content removed, tags
58
+ // dropped, entities decoded, whitespace collapsed).
59
+ const text = extractPlainTextFromHtml(html);
70
60
  // Extract "Date: MM/DD/YYYY" if present
71
61
  const dateMatch = text.match(/Date:\s*(\d{1,2}\/\d{1,2}\/\d{2,4})/);
72
62
  const date = dateMatch ? dateMatch[1] : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "mcpName": "io.github.chrischall/infinitecampus-mcp",
5
5
  "description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -42,10 +42,12 @@
42
42
  "test:watch": "vitest"
43
43
  },
44
44
  "dependencies": {
45
- "@fetchproxy/bootstrap": "^0.8.0",
46
- "@fetchproxy/server": "^0.8.0",
45
+ "@chrischall/mcp-utils": "^0.5.0",
46
+ "@fetchproxy/bootstrap": "^1.0.0",
47
+ "@fetchproxy/server": "^1.0.0",
47
48
  "@modelcontextprotocol/sdk": "^1.29.0",
48
49
  "dotenv": "^17.4.2",
50
+ "node-html-parser": "^7.1.0",
49
51
  "zod": "^4.4.3"
50
52
  },
51
53
  "devDependencies": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/infinitecampus-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.3.1",
9
+ "version": "2.3.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.3.1",
14
+ "version": "2.3.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },