infinitecampus-mcp 2.3.6 → 2.4.0
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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/dist/bundle.js +129 -9
- package/dist/index.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/infinitecampus-api/SKILL.md +142 -0
- package/skills/infinitecampus-api/references/endpoints.md +225 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Infinite Campus (Campus Parent) — grades, attendance, assignments, messages, documents",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.4.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Infinite Campus",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents via natural language",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinitecampus-mcp",
|
|
3
3
|
"displayName": "Infinite Campus",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
"k12",
|
|
19
19
|
"mcp"
|
|
20
20
|
],
|
|
21
|
-
"skills": "./
|
|
21
|
+
"skills": "./skills/",
|
|
22
22
|
"mcp": "./.mcp.json"
|
|
23
23
|
}
|
package/dist/bundle.js
CHANGED
|
@@ -34845,6 +34845,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
34845
34845
|
"capture_request_header",
|
|
34846
34846
|
"capture_redirect",
|
|
34847
34847
|
"read_indexed_db",
|
|
34848
|
+
"read_dom",
|
|
34848
34849
|
"download"
|
|
34849
34850
|
]);
|
|
34850
34851
|
|
|
@@ -35146,6 +35147,44 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35146
35147
|
}
|
|
35147
35148
|
}
|
|
35148
35149
|
}
|
|
35150
|
+
var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
|
|
35151
|
+
var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
|
|
35152
|
+
function assertDomSelectorsArray(value, label) {
|
|
35153
|
+
if (!Array.isArray(value)) {
|
|
35154
|
+
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
35155
|
+
}
|
|
35156
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35157
|
+
for (let i = 0; i < value.length; i++) {
|
|
35158
|
+
const entry = value[i];
|
|
35159
|
+
assertObject(entry, `${label}[${i}]`);
|
|
35160
|
+
if (entry.name === void 0) {
|
|
35161
|
+
throw new ProtocolError(`${label}[${i}].name: missing`);
|
|
35162
|
+
}
|
|
35163
|
+
if (entry.selector === void 0) {
|
|
35164
|
+
throw new ProtocolError(`${label}[${i}].selector: missing`);
|
|
35165
|
+
}
|
|
35166
|
+
if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
|
|
35167
|
+
throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
|
|
35168
|
+
}
|
|
35169
|
+
if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
|
|
35170
|
+
throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
|
|
35171
|
+
}
|
|
35172
|
+
if (entry.attribute !== void 0) {
|
|
35173
|
+
if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
|
|
35174
|
+
throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
|
|
35175
|
+
}
|
|
35176
|
+
}
|
|
35177
|
+
if (seen.has(entry.name)) {
|
|
35178
|
+
throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
|
|
35179
|
+
}
|
|
35180
|
+
seen.add(entry.name);
|
|
35181
|
+
for (const k of Object.keys(entry)) {
|
|
35182
|
+
if (k !== "name" && k !== "selector" && k !== "attribute") {
|
|
35183
|
+
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
35184
|
+
}
|
|
35185
|
+
}
|
|
35186
|
+
}
|
|
35187
|
+
}
|
|
35149
35188
|
function validateFrame(raw) {
|
|
35150
35189
|
assertObject(raw, "frame");
|
|
35151
35190
|
const t = raw.type;
|
|
@@ -35221,6 +35260,9 @@ function validateHello(raw) {
|
|
|
35221
35260
|
if (raw.sessionStoragePointers !== void 0) {
|
|
35222
35261
|
assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
|
|
35223
35262
|
}
|
|
35263
|
+
if (raw.domSelectors !== void 0) {
|
|
35264
|
+
assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
|
|
35265
|
+
}
|
|
35224
35266
|
assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
|
|
35225
35267
|
assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
|
|
35226
35268
|
assertBase64(raw.sessionNonce, "hello.sessionNonce");
|
|
@@ -35455,6 +35497,21 @@ function validateInnerRequest(raw) {
|
|
|
35455
35497
|
}
|
|
35456
35498
|
return raw;
|
|
35457
35499
|
}
|
|
35500
|
+
if (raw.op === "read_dom") {
|
|
35501
|
+
assertObject(raw.init, "inner.init");
|
|
35502
|
+
if (raw.init.origin === void 0)
|
|
35503
|
+
throw new ProtocolError("inner.init.origin: missing");
|
|
35504
|
+
if (raw.init.names === void 0)
|
|
35505
|
+
throw new ProtocolError("inner.init.names: missing");
|
|
35506
|
+
assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
|
|
35507
|
+
assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
|
|
35508
|
+
for (const k of Object.keys(raw.init)) {
|
|
35509
|
+
if (k !== "origin" && k !== "names") {
|
|
35510
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
|
|
35511
|
+
}
|
|
35512
|
+
}
|
|
35513
|
+
return raw;
|
|
35514
|
+
}
|
|
35458
35515
|
if (raw.op === "download") {
|
|
35459
35516
|
assertObject(raw.init, "inner.init");
|
|
35460
35517
|
if (raw.init.url === void 0) {
|
|
@@ -35480,7 +35537,7 @@ function validateInnerRequest(raw) {
|
|
|
35480
35537
|
}
|
|
35481
35538
|
return raw;
|
|
35482
35539
|
}
|
|
35483
|
-
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35540
|
+
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35484
35541
|
}
|
|
35485
35542
|
function assertNonEmptyKeyArray(value, label) {
|
|
35486
35543
|
if (!Array.isArray(value)) {
|
|
@@ -35565,6 +35622,13 @@ function validateInnerResponse(raw) {
|
|
|
35565
35622
|
assertObject(raw.values, "inner.values");
|
|
35566
35623
|
return raw;
|
|
35567
35624
|
}
|
|
35625
|
+
if (op === "read_dom") {
|
|
35626
|
+
if (raw.values === void 0) {
|
|
35627
|
+
throw new ProtocolError("inner.values: missing on read_dom response");
|
|
35628
|
+
}
|
|
35629
|
+
assertStringMap(raw.values, "inner.values");
|
|
35630
|
+
return raw;
|
|
35631
|
+
}
|
|
35568
35632
|
if (op === "download") {
|
|
35569
35633
|
assertObject(raw.value, "inner.value");
|
|
35570
35634
|
assertString(raw.value.path, "inner.value.path");
|
|
@@ -35904,6 +35968,13 @@ async function buildServerHello(opts) {
|
|
|
35904
35968
|
jsonPointer: d.jsonPointer
|
|
35905
35969
|
}));
|
|
35906
35970
|
}
|
|
35971
|
+
if (opts.domSelectors && opts.domSelectors.length > 0) {
|
|
35972
|
+
hello.domSelectors = opts.domSelectors.map((d) => ({
|
|
35973
|
+
name: d.name,
|
|
35974
|
+
selector: d.selector,
|
|
35975
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
35976
|
+
}));
|
|
35977
|
+
}
|
|
35907
35978
|
return hello;
|
|
35908
35979
|
}
|
|
35909
35980
|
|
|
@@ -35993,7 +36064,8 @@ async function startHost(opts) {
|
|
|
35993
36064
|
captureHeaders: opts.ownCaptureHeaders,
|
|
35994
36065
|
indexedDbScopes: opts.ownIndexedDbScopes,
|
|
35995
36066
|
localStoragePointers: opts.ownLocalStoragePointers,
|
|
35996
|
-
sessionStoragePointers: opts.ownSessionStoragePointers
|
|
36067
|
+
sessionStoragePointers: opts.ownSessionStoragePointers,
|
|
36068
|
+
domSelectors: opts.ownDomSelectors
|
|
35997
36069
|
});
|
|
35998
36070
|
const ownSessionNonce = fromB64(ownHello.sessionNonce);
|
|
35999
36071
|
let extensionWs = null;
|
|
@@ -36228,6 +36300,7 @@ async function startPeer(opts) {
|
|
|
36228
36300
|
sessionStorageKeys: opts.sessionStorageKeys,
|
|
36229
36301
|
captureHeaders: opts.captureHeaders,
|
|
36230
36302
|
indexedDbScopes: opts.indexedDbScopes,
|
|
36303
|
+
domSelectors: opts.domSelectors,
|
|
36231
36304
|
localStoragePointers: opts.localStoragePointers,
|
|
36232
36305
|
sessionStoragePointers: opts.sessionStoragePointers
|
|
36233
36306
|
});
|
|
@@ -36635,6 +36708,11 @@ var FetchproxyServer = class {
|
|
|
36635
36708
|
key: d.key,
|
|
36636
36709
|
jsonPointer: d.jsonPointer
|
|
36637
36710
|
})),
|
|
36711
|
+
domSelectors: (opts.domSelectors ?? []).map((d) => ({
|
|
36712
|
+
name: d.name,
|
|
36713
|
+
selector: d.selector,
|
|
36714
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36715
|
+
})),
|
|
36638
36716
|
// 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
|
|
36639
36717
|
// adapter was about to set these to the same numbers anyway; the
|
|
36640
36718
|
// back-door is `0` (explicit opt-out) if a caller genuinely wants
|
|
@@ -36755,6 +36833,7 @@ var FetchproxyServer = class {
|
|
|
36755
36833
|
ownIndexedDbScopes: this.opts.indexedDbScopes,
|
|
36756
36834
|
ownLocalStoragePointers: this.opts.localStoragePointers,
|
|
36757
36835
|
ownSessionStoragePointers: this.opts.sessionStoragePointers,
|
|
36836
|
+
ownDomSelectors: this.opts.domSelectors,
|
|
36758
36837
|
onPairCode: this.opts.onPairCode
|
|
36759
36838
|
});
|
|
36760
36839
|
this.hostHandle.onOwnInner((inner) => this.onInner(inner));
|
|
@@ -36782,7 +36861,8 @@ var FetchproxyServer = class {
|
|
|
36782
36861
|
captureHeaders: this.opts.captureHeaders,
|
|
36783
36862
|
indexedDbScopes: this.opts.indexedDbScopes,
|
|
36784
36863
|
localStoragePointers: this.opts.localStoragePointers,
|
|
36785
|
-
sessionStoragePointers: this.opts.sessionStoragePointers
|
|
36864
|
+
sessionStoragePointers: this.opts.sessionStoragePointers,
|
|
36865
|
+
domSelectors: this.opts.domSelectors
|
|
36786
36866
|
});
|
|
36787
36867
|
this.peerHandle.onInner((inner) => this.onInner(inner));
|
|
36788
36868
|
this.peerHandle.onRenegotiate(() => {
|
|
@@ -37757,6 +37837,46 @@ var FetchproxyServer = class {
|
|
|
37757
37837
|
await this.sendInnerFrame(inner);
|
|
37758
37838
|
return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
|
|
37759
37839
|
}
|
|
37840
|
+
/**
|
|
37841
|
+
* 1.4.0+: read declared DOM values from the user's signed-in tab.
|
|
37842
|
+
* Requires `'read_dom'` in capabilities AND every requested `name` to
|
|
37843
|
+
* match a declared `domSelectors` entry. The extension reads each
|
|
37844
|
+
* declared selector from the matched tab's DOM (isolated-world
|
|
37845
|
+
* `querySelector`, value or attribute) — no page-JS execution.
|
|
37846
|
+
*
|
|
37847
|
+
* Returns a `Record<string, string>` of `name → value`, with names
|
|
37848
|
+
* whose element (or attribute) was absent omitted. Throws
|
|
37849
|
+
* `FetchproxyProtocolError` on bridge failures and a plain `Error` on
|
|
37850
|
+
* developer mistakes (undeclared capability, undeclared name).
|
|
37851
|
+
*/
|
|
37852
|
+
async readDom(opts) {
|
|
37853
|
+
if (!this.opts.capabilities.includes("read_dom")) {
|
|
37854
|
+
throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
|
|
37855
|
+
}
|
|
37856
|
+
await this.ensureConnected();
|
|
37857
|
+
this.throwIfPendingPair();
|
|
37858
|
+
if (!Array.isArray(opts.names) || opts.names.length === 0) {
|
|
37859
|
+
throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
|
|
37860
|
+
}
|
|
37861
|
+
this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
|
|
37862
|
+
if (opts.subdomain !== void 0)
|
|
37863
|
+
assertSubdomainLabel(opts.subdomain);
|
|
37864
|
+
const baseDomain = this.resolveBaseDomain(opts.domain);
|
|
37865
|
+
const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
|
|
37866
|
+
const origin = `https://${host}`;
|
|
37867
|
+
const id = this.nextRequestId++;
|
|
37868
|
+
const inner = {
|
|
37869
|
+
type: "request",
|
|
37870
|
+
id,
|
|
37871
|
+
op: "read_dom",
|
|
37872
|
+
init: { origin, names: [...opts.names] }
|
|
37873
|
+
};
|
|
37874
|
+
const pending = new Promise((resolve, reject) => {
|
|
37875
|
+
this.pendingStorage.set(id, { resolve, reject });
|
|
37876
|
+
});
|
|
37877
|
+
await this.sendInnerFrame(inner);
|
|
37878
|
+
return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
|
|
37879
|
+
}
|
|
37760
37880
|
assertScopeSubset(requested, declared, label) {
|
|
37761
37881
|
const undeclared = undeclaredKeys(requested, declared);
|
|
37762
37882
|
if (undeclared.length > 0) {
|
|
@@ -37828,7 +37948,7 @@ var FetchproxyServer = class {
|
|
|
37828
37948
|
if (storageCb) {
|
|
37829
37949
|
this.pendingStorage.delete(inner.id);
|
|
37830
37950
|
if (inner.ok) {
|
|
37831
|
-
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
|
|
37951
|
+
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
|
|
37832
37952
|
storageCb.resolve({ ...inner.values });
|
|
37833
37953
|
} else {
|
|
37834
37954
|
storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
|
|
@@ -38191,7 +38311,7 @@ function loadAccount(env = process.env) {
|
|
|
38191
38311
|
// package.json
|
|
38192
38312
|
var package_default = {
|
|
38193
38313
|
name: "infinitecampus-mcp",
|
|
38194
|
-
version: "2.
|
|
38314
|
+
version: "2.4.0",
|
|
38195
38315
|
mcpName: "io.github.chrischall/infinitecampus-mcp",
|
|
38196
38316
|
description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
|
|
38197
38317
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -38233,19 +38353,19 @@ var package_default = {
|
|
|
38233
38353
|
"test:watch": "vitest"
|
|
38234
38354
|
},
|
|
38235
38355
|
dependencies: {
|
|
38236
|
-
"@chrischall/mcp-utils": "^0.
|
|
38356
|
+
"@chrischall/mcp-utils": "^0.13.0",
|
|
38237
38357
|
"@fetchproxy/bootstrap": "^1.0.0",
|
|
38238
38358
|
"@fetchproxy/server": "^1.0.0",
|
|
38239
38359
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38240
38360
|
dotenv: "^17.4.2",
|
|
38241
|
-
"node-html-parser": "^
|
|
38361
|
+
"node-html-parser": "^9.0.0",
|
|
38242
38362
|
zod: "^4.4.3"
|
|
38243
38363
|
},
|
|
38244
38364
|
devDependencies: {
|
|
38245
38365
|
"@types/node": "^26.0.0",
|
|
38246
38366
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38247
38367
|
esbuild: "^0.28.0",
|
|
38248
|
-
typescript: "^
|
|
38368
|
+
typescript: "^7.0.2",
|
|
38249
38369
|
vitest: "^4.1.7"
|
|
38250
38370
|
}
|
|
38251
38371
|
};
|
|
@@ -39807,7 +39927,7 @@ try {
|
|
|
39807
39927
|
}
|
|
39808
39928
|
var COMMON = {
|
|
39809
39929
|
name: "infinitecampus",
|
|
39810
|
-
version: "2.
|
|
39930
|
+
version: "2.4.0"
|
|
39811
39931
|
// x-release-please-version
|
|
39812
39932
|
};
|
|
39813
39933
|
if (account) {
|
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ catch (e) {
|
|
|
57
57
|
// actionable stderr message (banner) instead of a crash loop.
|
|
58
58
|
const COMMON = {
|
|
59
59
|
name: 'infinitecampus',
|
|
60
|
-
version: '2.
|
|
60
|
+
version: '2.4.0', // x-release-please-version
|
|
61
61
|
};
|
|
62
62
|
if (account) {
|
|
63
63
|
const client = new ICClient(account, { preloaded });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinitecampus-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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,19 +42,19 @@
|
|
|
42
42
|
"test:watch": "vitest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@chrischall/mcp-utils": "^0.
|
|
45
|
+
"@chrischall/mcp-utils": "^0.13.0",
|
|
46
46
|
"@fetchproxy/bootstrap": "^1.0.0",
|
|
47
47
|
"@fetchproxy/server": "^1.0.0",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"dotenv": "^17.4.2",
|
|
50
|
-
"node-html-parser": "^
|
|
50
|
+
"node-html-parser": "^9.0.0",
|
|
51
51
|
"zod": "^4.4.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^26.0.0",
|
|
55
55
|
"@vitest/coverage-v8": "^4.1.7",
|
|
56
56
|
"esbuild": "^0.28.0",
|
|
57
|
-
"typescript": "^
|
|
57
|
+
"typescript": "^7.0.2",
|
|
58
58
|
"vitest": "^4.1.7"
|
|
59
59
|
}
|
|
60
60
|
}
|
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.
|
|
9
|
+
"version": "2.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: infinitecampus-api
|
|
3
|
+
description: >-
|
|
4
|
+
Query an Infinite Campus (Campus Parent portal) district directly with curl
|
|
5
|
+
instead of running the infinitecampus-mcp server — log in with a real
|
|
6
|
+
username/password, capture the session cookie + XSRF token, and curl grades,
|
|
7
|
+
attendance, assignments, schedule, messages, documents, and fees. Use when
|
|
8
|
+
you want IC data without the MCP, in a script, or on a machine where the MCP
|
|
9
|
+
isn't installed. Infinite Campus is per-district: the base URL and district
|
|
10
|
+
app name are configurable, not hardcoded.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Infinite Campus via curl (no MCP)
|
|
14
|
+
|
|
15
|
+
Infinite Campus's Campus Parent portal is a classic server-rendered site
|
|
16
|
+
(no public API, no bot wall). A plain form POST to `verify.jsp` logs a
|
|
17
|
+
parent in and hands back a session cookie — no browser bridge needed. This
|
|
18
|
+
is the same login `infinitecampus-mcp`'s `src/client.ts`/`src/auth.ts` do;
|
|
19
|
+
this skill reproduces it with `curl` + a cookie jar.
|
|
20
|
+
|
|
21
|
+
**Infinite Campus is per-district.** Every parent's portal lives at their
|
|
22
|
+
own district's host (`https://campus.<district>.k12.example.us` or similar)
|
|
23
|
+
under an `appName` path segment. There is no shared IC endpoint — carry the
|
|
24
|
+
base URL and district from the same config the MCP uses:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
: "${IC_BASE_URL:?set to your district's portal URL, e.g. https://campus.yourdistrict.org}"
|
|
28
|
+
: "${IC_DISTRICT:?set to the appName path segment, e.g. yourdistrict}"
|
|
29
|
+
: "${IC_USERNAME:?parent portal username}"
|
|
30
|
+
: "${IC_PASSWORD:?parent portal password}"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(Same 4 env vars the MCP reads — reuse the repo's `.env` or the host's
|
|
34
|
+
secret store; don't hardcode a district.)
|
|
35
|
+
|
|
36
|
+
## One-time setup
|
|
37
|
+
|
|
38
|
+
None — `curl` and `jq` are the only tools needed (`brew install jq` if
|
|
39
|
+
missing). No profile/pairing step, no extension.
|
|
40
|
+
|
|
41
|
+
## Login (capture cookies + XSRF token)
|
|
42
|
+
|
|
43
|
+
`verify.jsp` accepts a urlencoded form POST and returns 200 with an
|
|
44
|
+
`<AUTHENTICATION>state</AUTHENTICATION>` marker in the body — **not**
|
|
45
|
+
via HTTP status — plus ~20 `Set-Cookie` headers. Use a cookie jar so curl
|
|
46
|
+
does the Set-Cookie parsing/deduping for you:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
JAR=$(mktemp)
|
|
50
|
+
BODY=$(curl -sS -c "$JAR" \
|
|
51
|
+
"$IC_BASE_URL/campus/verify.jsp?nonBrowser=true" \
|
|
52
|
+
--data-urlencode "username=$IC_USERNAME" \
|
|
53
|
+
--data-urlencode "password=$IC_PASSWORD" \
|
|
54
|
+
--data-urlencode "appName=$IC_DISTRICT" \
|
|
55
|
+
--data-urlencode "portalLoginPage=parents")
|
|
56
|
+
|
|
57
|
+
echo "$BODY" | grep -o '<AUTHENTICATION>[^<]*' # AUTHENTICATIONsuccess = good
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`password-error` / `account-locked` / any non-`success` state means the
|
|
61
|
+
login failed — check that string before doing anything else. Credentials
|
|
62
|
+
go in the form body (never the query string — it lands in access logs).
|
|
63
|
+
|
|
64
|
+
Pull the XSRF token out of the jar (IC's JS reads it back and echoes it on
|
|
65
|
+
every request as `X-XSRF-TOKEN`; `JSESSIONID` alone is not enough):
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
XSRF=$(awk -F'\t' '$6=="XSRF-TOKEN"{print $7}' "$JAR")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Core call pattern
|
|
72
|
+
|
|
73
|
+
Every subsequent request rides the jar + the XSRF header:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
ic_get() { # ic_get <path-with-query>
|
|
77
|
+
curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -H 'Accept: application/json' \
|
|
78
|
+
"$IC_BASE_URL$1"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ic_get '/campus/api/portal/students' | jq .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A **401** means the session expired (IC sessions last ~5-6h) — re-run the
|
|
85
|
+
Login step to get a fresh `$JAR`/`$XSRF` and retry once. There is no retry
|
|
86
|
+
budget beyond that in this shell version (the MCP's `CookieSessionManager`
|
|
87
|
+
does this automatically; here just redo the login block).
|
|
88
|
+
|
|
89
|
+
## The one rule: resolve student + enrollment first
|
|
90
|
+
|
|
91
|
+
Almost every read is scoped to a `personID` (student), and several need the
|
|
92
|
+
student's **enrollment** (`enrollmentID` / `calendarID` / `structureID`).
|
|
93
|
+
Always start with:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
ic_get '/campus/api/portal/students' | jq '.[] | {personID, firstName, lastName, enrollments}'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Take `personID` for `studentId`/`personID` params, and the first (or
|
|
100
|
+
relevant) `enrollments[]` entry's `enrollmentID`/`calendarID`/`structureID`
|
|
101
|
+
for the endpoints that need them (attendance, school days, assessments,
|
|
102
|
+
features). There is no name→id search — `ic_list_students` (i.e. this call)
|
|
103
|
+
*is* the resolve step.
|
|
104
|
+
|
|
105
|
+
## Endpoints
|
|
106
|
+
|
|
107
|
+
All 17 read endpoints (grades, attendance, assignments, schedule, messages,
|
|
108
|
+
documents, fees, teachers, assessments, feature flags) with ready-to-run
|
|
109
|
+
`curl`+`jq` recipes are in `references/endpoints.md`. Two (`behavior`,
|
|
110
|
+
`food_service`) have real paths that were never confirmed live against a
|
|
111
|
+
district with that module enabled — try them, expect a 404 if the module
|
|
112
|
+
is off or the district's shape differs.
|
|
113
|
+
|
|
114
|
+
## Downloads
|
|
115
|
+
|
|
116
|
+
`ic_download_document`'s `url` field (from the documents list) may be a
|
|
117
|
+
relative `/campus/...` path or an absolute URL. Same jar + header pattern,
|
|
118
|
+
just write to a file instead of piping to `jq`:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -o report-card.pdf "$IC_BASE_URL$DOC_URL"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## FeatureDisabled districts
|
|
125
|
+
|
|
126
|
+
Some districts turn whole modules off (behavior, food service, assessments,
|
|
127
|
+
documents, attendance). The MCP checks `displayOptions` first and falls
|
|
128
|
+
back to treating a 404 as "disabled." Doing the same by hand: fetch
|
|
129
|
+
`/campus/api/portal/displayOptions/{structureID}?personID=X` (see
|
|
130
|
+
references) and check the flag before assuming a 404 is a bug.
|
|
131
|
+
|
|
132
|
+
## Notes
|
|
133
|
+
|
|
134
|
+
- Read-only in practice: every recipe here is a GET. `ic_get_message`
|
|
135
|
+
fetching a message body *may* mark it read on some district configs — the
|
|
136
|
+
MCP's own probe couldn't confirm the side effect either way.
|
|
137
|
+
- No CUPS linked-district switching in this skill (a parent with kids in
|
|
138
|
+
2+ IC instances under shared SSO) — that's a multi-step token-exchange
|
|
139
|
+
flow (`src/client.ts`'s `discoverLinkedDistricts`); out of scope here.
|
|
140
|
+
Run the login flow again against the linked district's own
|
|
141
|
+
`IC_BASE_URL`/`IC_DISTRICT` if you need it.
|
|
142
|
+
- This project is developed and maintained by AI (Claude).
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Infinite Campus endpoints for curl
|
|
2
|
+
|
|
3
|
+
Assumes the Login block from `../SKILL.md` has already run (`$JAR`, `$XSRF`,
|
|
4
|
+
`$IC_BASE_URL` set) and the `ic_get` helper is defined:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
ic_get() { curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -H 'Accept: application/json' "$IC_BASE_URL$1"; }
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
All paths are transcribed from `infinitecampus-mcp`'s `src/tools/*.ts` and
|
|
11
|
+
`docs/endpoints.md` — the same requests the `ic_*` MCP tools make. `$PID` =
|
|
12
|
+
a student's `personID` from the resolve-first step.
|
|
13
|
+
|
|
14
|
+
Some IC/prism responses serialize a 1-item collection as a bare object
|
|
15
|
+
instead of a 1-element array — pipe through `jq 'if type=="array" then . else [.] end'`
|
|
16
|
+
if a recipe below assumes an array and you get an object back.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Students (resolve-first — always start here)
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
ic_get '/campus/api/portal/students' \
|
|
24
|
+
| jq '.[] | {personID, firstName, lastName, enrollments}'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each `enrollments[]` entry carries `enrollmentID`, `calendarID`,
|
|
28
|
+
`structureID`, `schoolName`, `endDate` — needed by several endpoints below.
|
|
29
|
+
|
|
30
|
+
## Schedule
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
ic_get "/campus/resources/portal/roster?personID=$PID" | jq .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(Only `personID` is honored server-side — no working date/term filter, even
|
|
37
|
+
though the MCP tool's schema accepts `date`/`termFilter` args.)
|
|
38
|
+
|
|
39
|
+
## Assignments
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# all assignments (full term history, ~hundreds of rows)
|
|
43
|
+
ic_get "/campus/api/portal/assignment/listView?personID=$PID" | jq .
|
|
44
|
+
|
|
45
|
+
# scoped to one course (sectionID from the schedule call above)
|
|
46
|
+
ic_get "/campus/api/portal/assignment/listView?personID=$PID§ionID=$SECTION_ID" | jq .
|
|
47
|
+
|
|
48
|
+
# client-side filters (server ignores date/missing params entirely):
|
|
49
|
+
ic_get "/campus/api/portal/assignment/listView?personID=$PID" \
|
|
50
|
+
| jq --arg since 2026-01-01 '[.[] | select(.dueDate >= $since)]'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Recent (recently-graded) assignments
|
|
54
|
+
|
|
55
|
+
Server-side filtered by `modifiedDate` (defaults to 14 days ago in the MCP;
|
|
56
|
+
reproduce with `date -u -v-14d '+%Y-%m-%dT00:00:00'` on macOS):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
SINCE=$(date -u -v-14d '+%Y-%m-%dT00:00:00')
|
|
60
|
+
ic_get "/campus/api/portal/assignment/recentlyScored?modifiedDate=$SINCE&personID=$PID" | jq .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Grades
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
ic_get "/campus/resources/portal/grades?personID=$PID" | jq .
|
|
67
|
+
# scoped to one term:
|
|
68
|
+
ic_get "/campus/resources/portal/grades?personID=$PID&termID=$TERM_ID" | jq .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Attendance (per-course summary, loop over each enrollment)
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
ic_get "/campus/resources/portal/attendance/$ENROLLMENT_ID?courseSummary=true&personID=$PID" \
|
|
75
|
+
| jq '.terms[]?.courses[]? | {absentList, tardyList, presentList, earlyReleaseList}'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Feature-gated: check `displayOptions.attendance` first (see Feature flags,
|
|
79
|
+
below) — a 404 here on a district with attendance disabled is expected, not
|
|
80
|
+
a bug.
|
|
81
|
+
|
|
82
|
+
## Attendance events (individual absences/tardies, loop over each enrollment)
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
ic_get "/campus/resources/portal/attendance/events?enrollmentID=$ENROLLMENT_ID&personID=$PID" \
|
|
86
|
+
| jq '.events[]? | {date: .localDate, code, description, excuse, comments}'
|
|
87
|
+
|
|
88
|
+
# excused-only, since a date (client-side filter):
|
|
89
|
+
ic_get "/campus/resources/portal/attendance/events?enrollmentID=$ENROLLMENT_ID&personID=$PID" \
|
|
90
|
+
| jq --arg since 2026-01-01 '[.events[]? | select((.localDate[0:10] >= $since) and (.excuse == "E"))]'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## School days / calendar (loop over each enrollment)
|
|
94
|
+
|
|
95
|
+
Two calls per enrollment, then join term windows to days client-side:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
ic_get "/campus/resources/term?structureID=$STRUCTURE_ID" | jq . # term boundaries
|
|
99
|
+
ic_get "/campus/resources/calendar/instructionalDay?calendarID=$CALENDAR_ID" | jq . # day list
|
|
100
|
+
|
|
101
|
+
# days within term N's [startDate,endDate]:
|
|
102
|
+
jq --slurpfile terms terms.json --arg t "$TERM_ID" \
|
|
103
|
+
'[.[] | select(.date >= $terms[0][0].startDate and .date <= $terms[0][0].endDate)]' days.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Assessments (standardized tests, loop over each enrollment)
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
ic_get "/campus/resources/prism/portal/assessments?personID=$PID&calendarID=$CALENDAR_ID" \
|
|
110
|
+
| jq '{stateTests, nationalTests, districtTests}'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Feature-gated on `displayOptions.assessment`; test-item shape varies by
|
|
114
|
+
district/test type — pass through unchanged, don't assume field names
|
|
115
|
+
beyond `stateTests`/`nationalTests`/`districtTests`.
|
|
116
|
+
|
|
117
|
+
## Behavior *(path unconfirmed — never verified live against a district with the module on)*
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
ic_get "/campus/resources/portal/behavior?personID=$PID" | jq .
|
|
121
|
+
# with a date range:
|
|
122
|
+
ic_get "/campus/resources/portal/behavior?personID=$PID&startDate=2026-01-01&endDate=2026-06-01" | jq .
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Feature-gated on `displayOptions.behavior` — a 404 is the expected result
|
|
126
|
+
on most districts (few enable this module for parent portal).
|
|
127
|
+
|
|
128
|
+
## Food service *(path unconfirmed — same caveat as behavior)*
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
ic_get "/campus/resources/portal/foodService?personID=$PID" | jq .
|
|
132
|
+
ic_get "/campus/resources/portal/foodService?personID=$PID&startDate=2026-01-01&endDate=2026-06-01" | jq .
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Feature-gated on `displayOptions.foodService`.
|
|
136
|
+
|
|
137
|
+
## Documents + download
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
ic_get "/campus/resources/portal/report/all?personID=$PID" \
|
|
141
|
+
| jq '.[] | {name, type, url, moduleLabel, endYear}'
|
|
142
|
+
|
|
143
|
+
# download (url may be relative /campus/... or an absolute URL — handle both):
|
|
144
|
+
DOC_URL='/campus/resources/portal/report/...' # from the .url field above
|
|
145
|
+
curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -o report-card.pdf \
|
|
146
|
+
"$( [[ "$DOC_URL" == http* ]] && echo "$DOC_URL" || echo "$IC_BASE_URL$DOC_URL" )"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Feature-gated on `displayOptions.documents`.
|
|
150
|
+
|
|
151
|
+
## Teachers + counselors
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
ic_get "/campus/resources/portal/section/contacts?personID=$PID" | jq .
|
|
155
|
+
ic_get "/campus/resources/portal/studentCounselor/byUser?personID=$PID" | jq .
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Either endpoint 404ing is treated as "no results" (not FeatureDisabled) by
|
|
159
|
+
the MCP — do the same.
|
|
160
|
+
|
|
161
|
+
## Messages (three independent sources — combine client-side)
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
# 1. prism notifications (assignment/grade/attendance alerts)
|
|
165
|
+
ic_get '/campus/prism?x=notifications.Notification-retrieve&limitCount=20' \
|
|
166
|
+
| jq '.data.NotificationList.Notification'
|
|
167
|
+
|
|
168
|
+
# 2. Messenger 2.0 inbox (teacher messages, district announcements)
|
|
169
|
+
ic_get '/campus/api/portal/process-message' | jq .
|
|
170
|
+
|
|
171
|
+
# 3. portal userNotice announcements
|
|
172
|
+
ic_get '/campus/resources/portal/userNotice' | jq .
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Each inbox item's `url` field feeds `ic_get_message` below (normalize a bare
|
|
176
|
+
or `portal/...`-relative URL to `/campus/portal/...` first).
|
|
177
|
+
|
|
178
|
+
## Get one message body (HTML → text)
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
# normalize: bare "portal/..." → "/campus/portal/..."; already-absolute /campus/... stays as-is
|
|
182
|
+
MSG_PATH='/campus/portal/messageView.xsl?x=messenger.MessengerEngine-getMessageRecipientView&messageID=...&messageRecipientID=...'
|
|
183
|
+
curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -H 'Accept: text/html' "$IC_BASE_URL$MSG_PATH"
|
|
184
|
+
# then strip tags/entities yourself, or eyeball the rendered HTML — this is
|
|
185
|
+
# an HTML fragment, not JSON, so `jq` doesn't apply.
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Fetching a message body *may* mark it read on some district configs
|
|
189
|
+
(unconfirmed either way).
|
|
190
|
+
|
|
191
|
+
## Fees (two endpoints in parallel; either can 404 independently)
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
ic_get "/campus/api/portal/fees/feeAssignments?personID=$PID" | jq .
|
|
195
|
+
ic_get "/campus/api/portal/fees/feeTransactionDetail/totalSurplus/-1?personID=$PID" | jq .
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Both 404 → module is off for this district. One 404 → report the other
|
|
199
|
+
side plus a note that the failing endpoint may be disabled.
|
|
200
|
+
|
|
201
|
+
## Feature flags (displayOptions — check before assuming a 404 is a bug)
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
ic_get "/campus/api/portal/displayOptions/$STRUCTURE_ID?personID=$PID" \
|
|
205
|
+
| jq '{attendance, behavior, assessment, documents, foodService, grades, schedule}'
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`false` = district has that module turned off for this enrollment; `true`
|
|
209
|
+
or absent = available. ~90 flags total; the MCP caches this per
|
|
210
|
+
`(district, structureID)` for the session lifetime since it rarely changes
|
|
211
|
+
mid-session — worth doing the same if you're calling it more than once.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Not covered here (out of scope for this skill)
|
|
216
|
+
|
|
217
|
+
- **CUPS linked-district discovery/switching** — a parent with kids at 2+
|
|
218
|
+
IC-hosted districts under shared SSO. Multi-step: fetch
|
|
219
|
+
`cups/linkedAccounts`, `userAccountSwitch/originalDistrict`,
|
|
220
|
+
`districts/current`, then per linked account a `cups/loginToken` POST
|
|
221
|
+
followed by a second `verify.jsp`-style POST at the *linked* district's
|
|
222
|
+
own host with the CUPS token. Fully discoverable in `src/client.ts`
|
|
223
|
+
(`discoverLinkedDistricts`) but a lot of shell for a one-shot skill —
|
|
224
|
+
just re-run the Login block against the linked district's own
|
|
225
|
+
`IC_BASE_URL`/`IC_DISTRICT` instead.
|