infinitecampus-mcp 2.3.1 → 2.3.2

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();
@@ -342,39 +343,23 @@ export class ICClient {
342
343
  }
343
344
  }
344
345
  /**
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
346
+ * Parse Set-Cookie headers into a deduplicated Cookie header + XSRF token via
347
+ * `parseCookieJar` (see inline comment for the splitter/jar details).
353
348
  */
354
349
  function parseSetCookies(headers) {
350
+ // Prefer the structured getSetCookie() split (one cookie per array entry,
351
+ // what real Node fetch Responses provide). Only when it's unavailable do we
352
+ // fall back to naively comma-splitting the joined header — kept identical to
353
+ // the original so the edge-case behavior (commas inside attribute lists) is
354
+ // preserved rather than inheriting parseCookieJar's smarter-but-different
355
+ // splitter. parseCookieJar then does the jar logic: drop deletion markers
356
+ // (Max-Age=0 / expired Expires) and empty values, dedupe by name. That's
357
+ // load-bearing so the synthesized Cookie header never carries both the
358
+ // `appName=` deletion and the real value (IC rejects "conflicting app name").
355
359
  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 };
360
+ const entries = raw.length > 0 ? raw : splitFallback(headers.get('set-cookie'));
361
+ const { cookies, cookieHeader } = parseCookieJar(entries);
362
+ return { cookieHeader, xsrfToken: cookies['XSRF-TOKEN'] ?? '' };
378
363
  }
379
364
  function splitFallback(header) {
380
365
  if (!header)
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.2', // 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.2",
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.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.3.1",
14
+ "version": "2.3.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },