infinitecampus-mcp 2.1.0 → 2.1.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.
@@ -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.2"
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.2",
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.2",
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/dist/bundle.js CHANGED
@@ -30118,12 +30118,21 @@ var StdioServerTransport = class {
30118
30118
  };
30119
30119
 
30120
30120
  // src/config.ts
30121
+ function readVar(env, key) {
30122
+ const raw = env[key];
30123
+ if (typeof raw !== "string") return void 0;
30124
+ const trimmed = raw.trim();
30125
+ if (trimmed.length === 0) return void 0;
30126
+ if (trimmed === "undefined" || trimmed === "null") return void 0;
30127
+ if (/^\$\{[^}]*\}$/.test(trimmed)) return void 0;
30128
+ return trimmed;
30129
+ }
30121
30130
  function loadAccount(env = process.env) {
30122
- const baseUrl = env.IC_BASE_URL;
30123
- const district = env.IC_DISTRICT;
30124
- const username = env.IC_USERNAME;
30125
- const password = env.IC_PASSWORD;
30126
- const name = env.IC_NAME || district;
30131
+ const baseUrl = readVar(env, "IC_BASE_URL");
30132
+ const district = readVar(env, "IC_DISTRICT");
30133
+ const username = readVar(env, "IC_USERNAME");
30134
+ const password = readVar(env, "IC_PASSWORD");
30135
+ const name = readVar(env, "IC_NAME") ?? district;
30127
30136
  const missing = [];
30128
30137
  if (!baseUrl) missing.push("IC_BASE_URL");
30129
30138
  if (!district) missing.push("IC_DISTRICT");
@@ -30188,8 +30197,12 @@ var ICClient = class {
30188
30197
  return data;
30189
30198
  }
30190
30199
  async request(district, path, opts = {}) {
30191
- const account2 = this.accounts.get(district);
30192
- if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
30200
+ let account2 = this.accounts.get(district);
30201
+ if (!account2) {
30202
+ await this.ensureDiscovery();
30203
+ account2 = this.accounts.get(district);
30204
+ if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
30205
+ }
30193
30206
  await this.ensureSession(account2);
30194
30207
  return this.doRequest(account2, path, opts, false);
30195
30208
  }
@@ -30218,11 +30231,21 @@ var ICClient = class {
30218
30231
  );
30219
30232
  if (postRes.status >= 500) throw new PortalUnreachableError(account2.name, postRes.status);
30220
30233
  const body = await postRes.text();
30221
- if (postRes.status >= 400 || body.includes("password-error")) {
30222
- throw new AuthFailedError(account2.name);
30234
+ if (postRes.status >= 400) {
30235
+ throw new AuthFailedError(account2.name, `HTTP ${postRes.status} from verify.jsp`);
30236
+ }
30237
+ const authMatch = body.match(/<AUTHENTICATION>([^<]+)<\/AUTHENTICATION>/i);
30238
+ const authState = authMatch?.[1]?.trim().toLowerCase() ?? "";
30239
+ if (authState === "password-error") {
30240
+ throw new AuthFailedError(account2.name, "IC returned password-error \u2014 wrong username or password");
30241
+ }
30242
+ if (authState && authState !== "success") {
30243
+ throw new AuthFailedError(account2.name, `IC returned authentication state '${authState}'`);
30223
30244
  }
30224
30245
  const cookies = parseSetCookies(postRes.headers);
30225
- if (!cookies.cookieHeader) throw new AuthFailedError(account2.name);
30246
+ if (!cookies.cookieHeader) {
30247
+ throw new AuthFailedError(account2.name, "login response missing session cookies");
30248
+ }
30226
30249
  const session = this.sessions.get(account2.name);
30227
30250
  session.cookie = cookies.cookieHeader;
30228
30251
  session.xsrfToken = cookies.xsrfToken;
@@ -30425,12 +30448,17 @@ var UnknownDistrictError = class extends Error {
30425
30448
  available;
30426
30449
  };
30427
30450
  var AuthFailedError = class extends Error {
30428
- constructor(district) {
30429
- super(`Login failed for district '${district}'. Check IC_USERNAME and IC_PASSWORD.`);
30451
+ constructor(district, reason) {
30452
+ const detail = reason ? ` (${reason})` : "";
30453
+ super(
30454
+ `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.`
30455
+ );
30430
30456
  this.district = district;
30457
+ this.reason = reason;
30431
30458
  this.name = "AuthFailedError";
30432
30459
  }
30433
30460
  district;
30461
+ reason;
30434
30462
  };
30435
30463
  var PortalUnreachableError = class extends Error {
30436
30464
  constructor(district, status) {
@@ -31318,7 +31346,7 @@ try {
31318
31346
  }
31319
31347
  var account = loadAccount();
31320
31348
  var client = new ICClient(account);
31321
- var server = new McpServer({ name: "infinitecampus", version: "2.1.0" });
31349
+ var server = new McpServer({ name: "infinitecampus", version: "2.1.2" });
31322
31350
  registerDistrictTools(server, client);
31323
31351
  registerStudentTools(server, client);
31324
31352
  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);
@@ -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/config.js CHANGED
@@ -1,9 +1,27 @@
1
+ /**
2
+ * Read an env var, trim whitespace, and treat as unset if blank or if the value
3
+ * looks like an unsubstituted shell placeholder (e.g. `${FOO}`) — defends
4
+ * against MCP hosts that pass .mcp.json env blocks through unexpanded.
5
+ */
6
+ 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;
18
+ }
1
19
  export function loadAccount(env = process.env) {
2
- const baseUrl = env.IC_BASE_URL;
3
- const district = env.IC_DISTRICT;
4
- const username = env.IC_USERNAME;
5
- const password = env.IC_PASSWORD;
6
- const name = env.IC_NAME || district;
20
+ const baseUrl = readVar(env, 'IC_BASE_URL');
21
+ const district = readVar(env, 'IC_DISTRICT');
22
+ const username = readVar(env, 'IC_USERNAME');
23
+ const password = readVar(env, 'IC_PASSWORD');
24
+ const name = readVar(env, 'IC_NAME') ?? district;
7
25
  const missing = [];
8
26
  if (!baseUrl)
9
27
  missing.push('IC_BASE_URL');
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.1.0' });
37
+ const server = new McpServer({ name: 'infinitecampus', version: '2.1.2' });
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.1.0",
3
+ "version": "2.1.2",
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.2",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "infinitecampus-mcp",
14
+ "version": "2.1.2",
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
+ }