infinitecampus-mcp 2.1.2 → 2.2.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
@@ -7,13 +7,50 @@ export class ICClient {
7
7
  linkedTo = new Map(); // linkedDistrictName → primaryDistrictName
8
8
  primaryName;
9
9
  featuresCache = new Map();
10
- constructor(account) {
10
+ /**
11
+ * True when the primary account has empty creds (fetchproxy mode). The
12
+ * client uses pre-loaded cookies in place of the `verify.jsp` POST and
13
+ * cannot re-login when those cookies expire — the user must re-sign-in
14
+ * in the browser. CUPS linked-district discovery still runs lazily on
15
+ * first call to `ensureDiscovery()`.
16
+ */
17
+ fetchproxyMode = false;
18
+ fetchproxyDiscoveryRan = false;
19
+ /**
20
+ * `preloaded` is the fetchproxy escape hatch: when set, the client treats
21
+ * the supplied cookies as a freshly-completed login on the primary account.
22
+ * This skips the `verify.jsp` POST entirely (the account has empty creds
23
+ * in this mode) but otherwise behaves identically — CUPS linked-district
24
+ * discovery still runs on first request, 401 retry still triggers a
25
+ * re-login. On a 401 with empty credentials we can't re-login from Node;
26
+ * the user must re-sign-in in the browser.
27
+ */
28
+ constructor(account, opts = {}) {
11
29
  this.accounts.set(account.name, account);
12
30
  this.primaryName = account.name;
31
+ if (opts.preloaded) {
32
+ this.sessions.set(account.name, {
33
+ cookie: opts.preloaded.cookieHeader,
34
+ xsrfToken: opts.preloaded.xsrfToken,
35
+ loggedInAt: Date.now(),
36
+ loginInFlight: null,
37
+ });
38
+ this.fetchproxyMode = true;
39
+ }
13
40
  }
14
41
  async ensureDiscovery() {
15
42
  // Ensure primary account is logged in, which triggers CUPS linked-district discovery
16
43
  await this.ensureSession(this.accounts.get(this.primaryName));
44
+ // fetchproxy mode skips login() and therefore the discovery call inside
45
+ // 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.
50
+ if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
51
+ this.fetchproxyDiscoveryRan = true;
52
+ await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
53
+ }
17
54
  }
18
55
  listDistricts() {
19
56
  return [...this.accounts.values()].map((a) => ({
@@ -50,6 +87,14 @@ export class ICClient {
50
87
  throw new UnknownDistrictError(district, [...this.accounts.keys()]);
51
88
  }
52
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.)
94
+ if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account.name === this.primaryName) {
95
+ this.fetchproxyDiscoveryRan = true;
96
+ await this.discoverLinkedDistricts(account);
97
+ }
53
98
  return this.doRequest(account, path, opts, false);
54
99
  }
55
100
  async ensureSession(account) {
@@ -73,6 +118,15 @@ export class ICClient {
73
118
  }
74
119
  }
75
120
  async login(account) {
121
+ // fetchproxy mode: empty primary creds, can't post to verify.jsp.
122
+ // 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.
126
+ if (!account.username || !account.password) {
127
+ throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
128
+ 'sign back into your IC portal in the browser and restart the MCP');
129
+ }
76
130
  // ic_parent_api's pattern: single POST to verify.jsp, let the response
77
131
  // set cookies. No pre-login GET needed (unlike OFW's Spring Security).
78
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' });
package/dist/config.js CHANGED
@@ -16,33 +16,53 @@ function readVar(env, key) {
16
16
  return undefined;
17
17
  return trimmed;
18
18
  }
19
+ /**
20
+ * Load the Account from env vars. IC_BASE_URL + IC_DISTRICT are ALWAYS
21
+ * required (the MCP needs to know which host to talk to and which district to
22
+ * dispatch on). IC_USERNAME + IC_PASSWORD must be set together or omitted
23
+ * together — partial creds are treated as a user mistake and throw rather
24
+ * than falling through to fetchproxy (which would mask the typo).
25
+ *
26
+ * When username/password are omitted the resolved Account carries empty
27
+ * strings. The caller (`resolveAuth()`) treats that as "try fetchproxy".
28
+ */
19
29
  export function loadAccount(env = process.env) {
20
30
  const baseUrl = readVar(env, 'IC_BASE_URL');
21
31
  const district = readVar(env, 'IC_DISTRICT');
22
32
  const username = readVar(env, 'IC_USERNAME');
23
33
  const password = readVar(env, 'IC_PASSWORD');
24
34
  const name = readVar(env, 'IC_NAME') ?? district;
35
+ // IC_BASE_URL + IC_DISTRICT are always required.
25
36
  const missing = [];
26
37
  if (!baseUrl)
27
38
  missing.push('IC_BASE_URL');
28
39
  if (!district)
29
40
  missing.push('IC_DISTRICT');
30
- if (!username)
31
- missing.push('IC_USERNAME');
32
- if (!password)
33
- missing.push('IC_PASSWORD');
34
41
  if (missing.length > 0) {
35
42
  throw new Error(`Missing required env var(s): ${missing.join(', ')}. ` +
36
- 'Set IC_BASE_URL, IC_DISTRICT, IC_USERNAME, and IC_PASSWORD.');
43
+ 'Set IC_BASE_URL (your portal URL) and IC_DISTRICT (the app-name path segment).');
37
44
  }
38
45
  if (!/^https:\/\//.test(baseUrl)) {
39
46
  throw new Error(`IC_BASE_URL must be an https URL, got: '${baseUrl}'`);
40
47
  }
48
+ // Username + password must be set together or omitted together. Partial
49
+ // configuration is almost always a typo, so surface it rather than silently
50
+ // falling through to fetchproxy.
51
+ if ((username && !password) || (!username && password)) {
52
+ const partialMissing = [];
53
+ if (!username)
54
+ partialMissing.push('IC_USERNAME');
55
+ if (!password)
56
+ partialMissing.push('IC_PASSWORD');
57
+ throw new Error(`Missing required env var(s) for password auth: ${partialMissing.join(', ')}. ` +
58
+ 'Set both IC_USERNAME and IC_PASSWORD, or leave both unset to use the fetchproxy ' +
59
+ 'fallback (requires the fetchproxy browser extension and a signed-in IC portal tab).');
60
+ }
41
61
  return {
42
62
  name: name,
43
63
  baseUrl: baseUrl.replace(/\/$/, ''),
44
64
  district: district,
45
- username: username,
46
- password: password,
65
+ username: username ?? '',
66
+ password: password ?? '',
47
67
  };
48
68
  }
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ catch {
13
13
  }
14
14
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
15
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
- import { loadAccount } from './config.js';
16
+ import { resolveAuth } from './auth.js';
17
17
  import { ICClient } from './client.js';
18
18
  import { registerDistrictTools } from './tools/districts.js';
19
19
  import { registerStudentTools } from './tools/students.js';
@@ -32,27 +32,51 @@ import { registerTeacherTools } from './tools/teachers.js';
32
32
  import { registerAssessmentTools } from './tools/assessments.js';
33
33
  import { registerFeeTools } from './tools/fees.js';
34
34
  import { registerFeaturesTools } from './tools/features.js';
35
- const account = loadAccount();
36
- const client = new ICClient(account);
37
- const server = new McpServer({ name: 'infinitecampus', version: '2.1.2' });
38
- registerDistrictTools(server, client);
39
- registerStudentTools(server, client);
40
- registerScheduleTools(server, client);
41
- registerAssignmentTools(server, client);
42
- registerGradeTools(server, client);
43
- registerAttendanceTools(server, client);
44
- registerBehaviorTools(server, client);
45
- registerFoodServiceTools(server, client);
46
- registerMessageTools(server, client);
47
- registerDocumentTools(server, client);
48
- registerCalendarTools(server, client);
49
- registerAttendanceEventsTools(server, client);
50
- registerRecentGradesTools(server, client);
51
- registerTeacherTools(server, client);
52
- registerAssessmentTools(server, client);
53
- registerFeeTools(server, client);
54
- registerFeaturesTools(server, client);
55
- console.error(`[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})`);
35
+ // Defer config errors so the server can still start cleanly when env vars
36
+ // aren't set (e.g. during the host's install-time smoke test, before the
37
+ // user has filled in user_config OR the user hasn't yet signed into their
38
+ // IC portal in the browser). When not configured we register no tools and
39
+ // log a clear stderr message — far better than the previous crash loop.
40
+ let account = null;
41
+ let preloaded;
42
+ let source;
43
+ let configError = null;
44
+ try {
45
+ const resolved = await resolveAuth();
46
+ account = resolved.account;
47
+ preloaded = resolved.preloaded;
48
+ source = resolved.source;
49
+ }
50
+ catch (e) {
51
+ configError = e;
52
+ }
53
+ const server = new McpServer({ name: 'infinitecampus', version: '2.2.3' }); // x-release-please-version
54
+ if (account) {
55
+ 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);
73
+ const suffix = source === 'fetchproxy' ? ' [via fetchproxy]' : '';
74
+ console.error(`[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`);
75
+ }
76
+ 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.');
79
+ }
56
80
  console.error('[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
57
81
  const transport = new StdioServerTransport();
58
82
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.1.2",
3
+ "version": "2.2.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>",
@@ -36,21 +36,22 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "build": "tsc && npm run bundle",
39
- "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
39
+ "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
40
  "dev": "node --env-file=.env dist/index.js",
41
41
  "test": "vitest run",
42
42
  "test:watch": "vitest"
43
43
  },
44
44
  "dependencies": {
45
+ "@fetchproxy/bootstrap": "^0.6.0",
45
46
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "dotenv": "^17.4.0",
47
- "zod": "^4.3.6"
47
+ "dotenv": "^17.4.2",
48
+ "zod": "^4.4.3"
48
49
  },
49
50
  "devDependencies": {
50
- "@types/node": "^25.5.2",
51
- "@vitest/coverage-v8": "^4.1.2",
51
+ "@types/node": "^25.9.1",
52
+ "@vitest/coverage-v8": "^4.1.7",
52
53
  "esbuild": "^0.28.0",
53
- "typescript": "^6.0.2",
54
- "vitest": "^4.1.2"
54
+ "typescript": "^6.0.3",
55
+ "vitest": "^4.1.7"
55
56
  }
56
57
  }
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.1.2",
9
+ "version": "2.2.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.2.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -30,14 +30,14 @@
30
30
  },
31
31
  {
32
32
  "name": "IC_USERNAME",
33
- "description": "Campus Parent portal username",
34
- "isRequired": true,
33
+ "description": "Campus Parent portal username. Optional: leave unset to use the fetchproxy browser-extension fallback.",
34
+ "isRequired": false,
35
35
  "format": "string"
36
36
  },
37
37
  {
38
38
  "name": "IC_PASSWORD",
39
- "description": "Campus Parent portal password",
40
- "isRequired": true,
39
+ "description": "Campus Parent portal password. Required only when IC_USERNAME is set.",
40
+ "isRequired": false,
41
41
  "format": "string",
42
42
  "isSecret": true
43
43
  },
@@ -46,6 +46,12 @@
46
46
  "description": "Friendly name for the district (defaults to district appname)",
47
47
  "isRequired": false,
48
48
  "format": "string"
49
+ },
50
+ {
51
+ "name": "IC_DISABLE_FETCHPROXY",
52
+ "description": "Set to 1 to skip the fetchproxy browser-extension fallback (missing creds become a hard error — useful in headless CI).",
53
+ "isRequired": false,
54
+ "format": "string"
49
55
  }
50
56
  ]
51
57
  }