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.
@@ -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.3.6"
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.3.6",
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.3.6",
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": "./SKILL.md",
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.3.6",
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.12.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": "^8.0.2",
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: "^6.0.3",
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.3.6"
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.3.6', // x-release-please-version
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.6",
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.12.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": "^8.0.2",
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": "^6.0.3",
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.3.6",
9
+ "version": "2.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.3.6",
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&sectionID=$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.