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.
- package/.claude-plugin/marketplace.json +36 -0
- package/.claude-plugin/plugin.json +20 -8
- package/.mcp.json +15 -0
- package/dist/bundle.js +41 -13
- package/dist/client.js +36 -11
- package/dist/config.js +23 -5
- package/dist/index.js +1 -1
- package/package.json +4 -2
- package/server.json +53 -0
|
@@ -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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
30123
|
-
const district = env
|
|
30124
|
-
const username = env
|
|
30125
|
-
const password = env
|
|
30126
|
-
const name = env
|
|
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
|
-
|
|
30192
|
-
if (!account2)
|
|
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
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
41
|
-
if (!account)
|
|
42
|
-
|
|
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
|
-
//
|
|
73
|
-
// body
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
3
|
-
const district = env
|
|
4
|
-
const username = env
|
|
5
|
-
const password = env
|
|
6
|
-
const name = env
|
|
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.
|
|
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.
|
|
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
|
+
}
|