infinitecampus-mcp 2.0.2 → 2.1.1

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.
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "infinitecampus-mcp",
4
+ "owner": {
5
+ "name": "Chris Hall",
6
+ "email": "chris.c.hall@gmail.com"
7
+ },
8
+ "metadata": {
9
+ "description": "MCP server for Infinite Campus (Campus Parent) — grades, attendance, assignments, messages, documents",
10
+ "version": "2.1.1"
11
+ },
12
+ "plugins": [
13
+ {
14
+ "name": "infinitecampus-mcp",
15
+ "displayName": "Infinite Campus",
16
+ "source": "./",
17
+ "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents via natural language",
18
+ "version": "2.1.1",
19
+ "author": {
20
+ "name": "Chris Hall"
21
+ },
22
+ "homepage": "https://github.com/chrischall/infinitecampus-mcp",
23
+ "repository": "https://github.com/chrischall/infinitecampus-mcp",
24
+ "license": "MIT",
25
+ "keywords": [
26
+ "infinite-campus",
27
+ "campus-parent",
28
+ "school",
29
+ "education",
30
+ "k12",
31
+ "mcp"
32
+ ],
33
+ "category": "productivity"
34
+ }
35
+ ]
36
+ }
@@ -1,11 +1,23 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "0.1.0",
4
- "description": "Infinite Campus Campus Parent MCP server (multi-district)",
5
- "mcp_servers": {
6
- "infinitecampus": {
7
- "command": "node",
8
- "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bundle.js"]
9
- }
10
- }
3
+ "displayName": "Infinite Campus",
4
+ "version": "2.1.1",
5
+ "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents",
6
+ "author": {
7
+ "name": "Chris Hall",
8
+ "email": "chris.c.hall@gmail.com"
9
+ },
10
+ "homepage": "https://github.com/chrischall/infinitecampus-mcp",
11
+ "repository": "https://github.com/chrischall/infinitecampus-mcp",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "infinite-campus",
15
+ "campus-parent",
16
+ "school",
17
+ "education",
18
+ "k12",
19
+ "mcp"
20
+ ],
21
+ "skills": "./SKILL.md",
22
+ "mcp": "./.mcp.json"
11
23
  }
package/.mcp.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "mcpServers": {
3
+ "infinitecampus": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/bundle.js"],
6
+ "env": {
7
+ "IC_BASE_URL": "${IC_BASE_URL}",
8
+ "IC_DISTRICT": "${IC_DISTRICT}",
9
+ "IC_USERNAME": "${IC_USERNAME}",
10
+ "IC_PASSWORD": "${IC_PASSWORD}",
11
+ "IC_NAME": "${IC_NAME}"
12
+ }
13
+ }
14
+ }
15
+ }
package/README.md CHANGED
@@ -31,11 +31,11 @@ Tools that the harness will gate as write/IO operations: `ic_download_document`.
31
31
  Set a single set of env vars for your primary Infinite Campus account:
32
32
 
33
33
  ```
34
- IC_BASE_URL=https://600.ncsis.gov
35
- IC_DISTRICT=psu600cms
34
+ IC_BASE_URL=https://campus.springfield.k12.example.us
35
+ IC_DISTRICT=springfield
36
36
  IC_USERNAME=parent@example.com
37
37
  IC_PASSWORD=...
38
- IC_NAME=Myers Park # optional, defaults to IC_DISTRICT
38
+ IC_NAME=Springfield # optional, defaults to IC_DISTRICT
39
39
  ```
40
40
 
41
41
  Linked districts (via CUPS SSO) are auto-discovered after primary login — a parent with kids in two districts only configures the primary. No extra config needed. If you have truly separate IC instances with different credentials, run two MCP instances.
package/dist/bundle.js CHANGED
@@ -30188,8 +30188,12 @@ var ICClient = class {
30188
30188
  return data;
30189
30189
  }
30190
30190
  async request(district, path, opts = {}) {
30191
- const account2 = this.accounts.get(district);
30192
- if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
30191
+ let account2 = this.accounts.get(district);
30192
+ if (!account2) {
30193
+ await this.ensureDiscovery();
30194
+ account2 = this.accounts.get(district);
30195
+ if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
30196
+ }
30193
30197
  await this.ensureSession(account2);
30194
30198
  return this.doRequest(account2, path, opts, false);
30195
30199
  }
@@ -30218,11 +30222,21 @@ var ICClient = class {
30218
30222
  );
30219
30223
  if (postRes.status >= 500) throw new PortalUnreachableError(account2.name, postRes.status);
30220
30224
  const body = await postRes.text();
30221
- if (postRes.status >= 400 || body.includes("password-error")) {
30222
- throw new AuthFailedError(account2.name);
30225
+ if (postRes.status >= 400) {
30226
+ throw new AuthFailedError(account2.name, `HTTP ${postRes.status} from verify.jsp`);
30227
+ }
30228
+ const authMatch = body.match(/<AUTHENTICATION>([^<]+)<\/AUTHENTICATION>/i);
30229
+ const authState = authMatch?.[1]?.trim().toLowerCase() ?? "";
30230
+ if (authState === "password-error") {
30231
+ throw new AuthFailedError(account2.name, "IC returned password-error \u2014 wrong username or password");
30232
+ }
30233
+ if (authState && authState !== "success") {
30234
+ throw new AuthFailedError(account2.name, `IC returned authentication state '${authState}'`);
30223
30235
  }
30224
30236
  const cookies = parseSetCookies(postRes.headers);
30225
- if (!cookies.cookieHeader) throw new AuthFailedError(account2.name);
30237
+ if (!cookies.cookieHeader) {
30238
+ throw new AuthFailedError(account2.name, "login response missing session cookies");
30239
+ }
30226
30240
  const session = this.sessions.get(account2.name);
30227
30241
  session.cookie = cookies.cookieHeader;
30228
30242
  session.xsrfToken = cookies.xsrfToken;
@@ -30425,12 +30439,17 @@ var UnknownDistrictError = class extends Error {
30425
30439
  available;
30426
30440
  };
30427
30441
  var AuthFailedError = class extends Error {
30428
- constructor(district) {
30429
- super(`Login failed for district '${district}'. Check IC_USERNAME and IC_PASSWORD.`);
30442
+ constructor(district, reason) {
30443
+ const detail = reason ? ` (${reason})` : "";
30444
+ super(
30445
+ `Login failed for district '${district}'${detail}. Check IC_USERNAME and IC_PASSWORD; if those are correct, the account may be locked or the portal may be down.`
30446
+ );
30430
30447
  this.district = district;
30448
+ this.reason = reason;
30431
30449
  this.name = "AuthFailedError";
30432
30450
  }
30433
30451
  district;
30452
+ reason;
30434
30453
  };
30435
30454
  var PortalUnreachableError = class extends Error {
30436
30455
  constructor(district, status) {
@@ -31318,7 +31337,7 @@ try {
31318
31337
  }
31319
31338
  var account = loadAccount();
31320
31339
  var client = new ICClient(account);
31321
- var server = new McpServer({ name: "infinitecampus", version: "2.0.2" });
31340
+ var server = new McpServer({ name: "infinitecampus", version: "2.1.1" });
31322
31341
  registerDistrictTools(server, client);
31323
31342
  registerStudentTools(server, client);
31324
31343
  registerScheduleTools(server, client);
package/dist/client.js CHANGED
@@ -37,9 +37,18 @@ export class ICClient {
37
37
  return data;
38
38
  }
39
39
  async request(district, path, opts = {}) {
40
- const account = this.accounts.get(district);
41
- if (!account)
42
- throw new UnknownDistrictError(district, [...this.accounts.keys()]);
40
+ let account = this.accounts.get(district);
41
+ if (!account) {
42
+ // Cold-start: linked districts are only added to the accounts map after
43
+ // primary login + CUPS discovery. If the caller asks for a linked
44
+ // district before any other request triggered login, we'd otherwise
45
+ // throw UnknownDistrictError despite the district being valid. Run
46
+ // discovery once before giving up.
47
+ await this.ensureDiscovery();
48
+ account = this.accounts.get(district);
49
+ if (!account)
50
+ throw new UnknownDistrictError(district, [...this.accounts.keys()]);
51
+ }
43
52
  await this.ensureSession(account);
44
53
  return this.doRequest(account, path, opts, false);
45
54
  }
@@ -69,16 +78,27 @@ export class ICClient {
69
78
  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' });
70
79
  if (postRes.status >= 500)
71
80
  throw new PortalUnreachableError(account.name, postRes.status);
72
- // Check for login failure — IC returns 200 with "password-error" in the
73
- // body on bad credentials, not a 4xx status code.
81
+ // IC's verify.jsp returns 200 with an <AUTHENTICATION>X</AUTHENTICATION>
82
+ // body where X is the auth state (success / password-error / account-locked
83
+ // / etc.). 4xx is also possible on misconfigured endpoints. Surface the
84
+ // actual reason so the LLM can give the user something useful.
74
85
  const body = await postRes.text();
75
- if (postRes.status >= 400 || body.includes('password-error')) {
76
- throw new AuthFailedError(account.name);
86
+ if (postRes.status >= 400) {
87
+ throw new AuthFailedError(account.name, `HTTP ${postRes.status} from verify.jsp`);
88
+ }
89
+ const authMatch = body.match(/<AUTHENTICATION>([^<]+)<\/AUTHENTICATION>/i);
90
+ const authState = authMatch?.[1]?.trim().toLowerCase() ?? '';
91
+ if (authState === 'password-error') {
92
+ throw new AuthFailedError(account.name, 'IC returned password-error — wrong username or password');
93
+ }
94
+ if (authState && authState !== 'success') {
95
+ throw new AuthFailedError(account.name, `IC returned authentication state '${authState}'`);
77
96
  }
78
97
  // Capture cookies, deduplicating and filtering out deletions (Max-Age=0).
79
98
  const cookies = parseSetCookies(postRes.headers);
80
- if (!cookies.cookieHeader)
81
- throw new AuthFailedError(account.name);
99
+ if (!cookies.cookieHeader) {
100
+ throw new AuthFailedError(account.name, 'login response missing session cookies');
101
+ }
82
102
  // Mutate the in-map session in place so concurrent callers'
83
103
  // references stay live (see ensureSession).
84
104
  const session = this.sessions.get(account.name);
@@ -271,7 +291,7 @@ export class ICClient {
271
291
  * Parse Set-Cookie headers into a deduplicated cookie string + XSRF token.
272
292
  *
273
293
  * IC's login response sets ~20 cookies including deletion markers (Max-Age=0).
274
- * Sending both `appName=` (delete) and `appName=psu600cms` (set) causes IC to
294
+ * Sending both `appName=` (delete) and `appName=springfield` (set) causes IC to
275
295
  * reject requests with "conflicting app name values". This parser:
276
296
  * - Filters out cookies with Max-Age=0 (deletion markers)
277
297
  * - Deduplicates by name (last value wins)
@@ -319,9 +339,14 @@ export class UnknownDistrictError extends Error {
319
339
  }
320
340
  export class AuthFailedError extends Error {
321
341
  district;
322
- constructor(district) {
323
- super(`Login failed for district '${district}'. Check IC_USERNAME and IC_PASSWORD.`);
342
+ reason;
343
+ constructor(district, reason) {
344
+ const detail = reason ? ` (${reason})` : '';
345
+ super(`Login failed for district '${district}'${detail}. ` +
346
+ `Check IC_USERNAME and IC_PASSWORD; ` +
347
+ `if those are correct, the account may be locked or the portal may be down.`);
324
348
  this.district = district;
349
+ this.reason = reason;
325
350
  this.name = 'AuthFailedError';
326
351
  }
327
352
  }
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ import { registerFeeTools } from './tools/fees.js';
34
34
  import { registerFeaturesTools } from './tools/features.js';
35
35
  const account = loadAccount();
36
36
  const client = new ICClient(account);
37
- const server = new McpServer({ name: 'infinitecampus', version: '2.0.2' });
37
+ const server = new McpServer({ name: 'infinitecampus', version: '2.1.1' });
38
38
  registerDistrictTools(server, client);
39
39
  registerStudentTools(server, client);
40
40
  registerScheduleTools(server, client);
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
+ "mcpName": "io.github.chrischall/infinitecampus-mcp",
4
5
  "description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
5
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
6
7
  "repository": {
@@ -30,7 +31,8 @@
30
31
  "dist",
31
32
  ".claude-plugin",
32
33
  "skills",
33
- ".mcp.json"
34
+ ".mcp.json",
35
+ "server.json"
34
36
  ],
35
37
  "scripts": {
36
38
  "build": "tsc && npm run bundle",
package/server.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.chrischall/infinitecampus-mcp",
4
+ "description": "Infinite Campus (Campus Parent) for Claude — grades, attendance, assignments, messages",
5
+ "repository": {
6
+ "url": "https://github.com/chrischall/infinitecampus-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "2.1.1",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "infinitecampus-mcp",
14
+ "version": "2.1.1",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "IC_BASE_URL",
21
+ "description": "Campus Parent portal base URL, e.g. https://campus.district.k12.example.us",
22
+ "isRequired": true,
23
+ "format": "string"
24
+ },
25
+ {
26
+ "name": "IC_DISTRICT",
27
+ "description": "Infinite Campus district app name (appName path segment from the portal URL)",
28
+ "isRequired": true,
29
+ "format": "string"
30
+ },
31
+ {
32
+ "name": "IC_USERNAME",
33
+ "description": "Campus Parent portal username",
34
+ "isRequired": true,
35
+ "format": "string"
36
+ },
37
+ {
38
+ "name": "IC_PASSWORD",
39
+ "description": "Campus Parent portal password",
40
+ "isRequired": true,
41
+ "format": "string",
42
+ "isSecret": true
43
+ },
44
+ {
45
+ "name": "IC_NAME",
46
+ "description": "Friendly name for the district (defaults to district appname)",
47
+ "isRequired": false,
48
+ "format": "string"
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ }