infinitecampus-mcp 2.3.6 → 2.4.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.
@@ -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.2"
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.2",
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.2",
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
@@ -3776,6 +3776,7 @@ var require_fast_uri = __commonJS({
3776
3776
  return uriTokens.join("");
3777
3777
  }
3778
3778
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3779
+ var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3779
3780
  function getParseError(parsed, matches) {
3780
3781
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3781
3782
  return 'URI path must start with "/" when authority is present.';
@@ -3805,6 +3806,11 @@ var require_fast_uri = __commonJS({
3805
3806
  uri = "//" + uri;
3806
3807
  }
3807
3808
  }
3809
+ const authorityMatch = uri.match(AUTHORITY_PREFIX);
3810
+ if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
3811
+ parsed.error = "URI authority must not contain a literal backslash.";
3812
+ malformedAuthorityOrPort = true;
3813
+ }
3808
3814
  const matches = uri.match(URI_PARSE);
3809
3815
  if (matches) {
3810
3816
  parsed.scheme = matches[1];
@@ -3848,7 +3854,7 @@ var require_fast_uri = __commonJS({
3848
3854
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
3849
3855
  if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
3850
3856
  try {
3851
- parsed.host = URL.domainToASCII(parsed.host.toLowerCase());
3857
+ parsed.host = new URL("http://" + parsed.host).hostname;
3852
3858
  } catch (e) {
3853
3859
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
3854
3860
  }
@@ -7693,6 +7699,7 @@ var require_receiver = __commonJS({
7693
7699
  this._opcode = 0;
7694
7700
  this._totalPayloadLength = 0;
7695
7701
  this._messageLength = 0;
7702
+ this._numFragments = 0;
7696
7703
  this._fragments = [];
7697
7704
  this._errored = false;
7698
7705
  this._loop = false;
@@ -8043,23 +8050,23 @@ var require_receiver = __commonJS({
8043
8050
  this.controlMessage(data, cb);
8044
8051
  return;
8045
8052
  }
8053
+ if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
8054
+ const error51 = this.createError(
8055
+ RangeError,
8056
+ "Too many message fragments",
8057
+ false,
8058
+ 1008,
8059
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8060
+ );
8061
+ cb(error51);
8062
+ return;
8063
+ }
8046
8064
  if (this._compressed) {
8047
8065
  this._state = INFLATING;
8048
8066
  this.decompress(data, cb);
8049
8067
  return;
8050
8068
  }
8051
8069
  if (data.length) {
8052
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8053
- const error51 = this.createError(
8054
- RangeError,
8055
- "Too many message fragments",
8056
- false,
8057
- 1008,
8058
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8059
- );
8060
- cb(error51);
8061
- return;
8062
- }
8063
8070
  this._messageLength = this._totalPayloadLength;
8064
8071
  this._fragments.push(data);
8065
8072
  }
@@ -8089,17 +8096,6 @@ var require_receiver = __commonJS({
8089
8096
  cb(error51);
8090
8097
  return;
8091
8098
  }
8092
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8093
- const error51 = this.createError(
8094
- RangeError,
8095
- "Too many message fragments",
8096
- false,
8097
- 1008,
8098
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8099
- );
8100
- cb(error51);
8101
- return;
8102
- }
8103
8099
  this._fragments.push(buf);
8104
8100
  }
8105
8101
  this.dataMessage(cb);
@@ -8122,6 +8118,7 @@ var require_receiver = __commonJS({
8122
8118
  this._totalPayloadLength = 0;
8123
8119
  this._messageLength = 0;
8124
8120
  this._fragmented = 0;
8121
+ this._numFragments = 0;
8125
8122
  this._fragments = [];
8126
8123
  if (this._opcode === 2) {
8127
8124
  let data;
@@ -9622,8 +9619,8 @@ var require_websocket = __commonJS({
9622
9619
  autoPong: true,
9623
9620
  closeTimeout: CLOSE_TIMEOUT,
9624
9621
  protocolVersion: protocolVersions[1],
9625
- maxBufferedChunks: 1024 * 1024,
9626
- maxFragments: 128 * 1024,
9622
+ maxBufferedChunks: 256 * 1024,
9623
+ maxFragments: 16 * 1024,
9627
9624
  maxPayload: 100 * 1024 * 1024,
9628
9625
  skipUTF8Validation: false,
9629
9626
  perMessageDeflate: true,
@@ -10210,9 +10207,9 @@ var require_websocket_server = __commonJS({
10210
10207
  * called
10211
10208
  * @param {Function} [options.handleProtocols] A hook to handle protocols
10212
10209
  * @param {String} [options.host] The hostname where to bind the server
10213
- * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
10210
+ * @param {Number} [options.maxBufferedChunks=262144] The maximum number of
10214
10211
  * buffered data chunks
10215
- * @param {Number} [options.maxFragments=131072] The maximum number of message
10212
+ * @param {Number} [options.maxFragments=16384] The maximum number of message
10216
10213
  * fragments
10217
10214
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
10218
10215
  * size
@@ -10235,8 +10232,8 @@ var require_websocket_server = __commonJS({
10235
10232
  options = {
10236
10233
  allowSynchronousEvents: true,
10237
10234
  autoPong: true,
10238
- maxBufferedChunks: 1024 * 1024,
10239
- maxFragments: 128 * 1024,
10235
+ maxBufferedChunks: 256 * 1024,
10236
+ maxFragments: 16 * 1024,
10240
10237
  maxPayload: 100 * 1024 * 1024,
10241
10238
  skipUTF8Validation: false,
10242
10239
  perMessageDeflate: false,
@@ -34845,7 +34842,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34845
34842
  "capture_request_header",
34846
34843
  "capture_redirect",
34847
34844
  "read_indexed_db",
34848
- "download"
34845
+ "read_dom",
34846
+ "download",
34847
+ "graphql"
34849
34848
  ]);
34850
34849
 
34851
34850
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -35146,6 +35145,76 @@ function assertIndexedDbScopesArray(value, label) {
35146
35145
  }
35147
35146
  }
35148
35147
  }
35148
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35149
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35150
+ var GRAPHQL_OP_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]{0,127}$/;
35151
+ function assertDomSelectorsArray(value, label) {
35152
+ if (!Array.isArray(value)) {
35153
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35154
+ }
35155
+ const seen = /* @__PURE__ */ new Set();
35156
+ for (let i = 0; i < value.length; i++) {
35157
+ const entry = value[i];
35158
+ assertObject(entry, `${label}[${i}]`);
35159
+ if (entry.name === void 0) {
35160
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35161
+ }
35162
+ if (entry.selector === void 0) {
35163
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35164
+ }
35165
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35166
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35167
+ }
35168
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35169
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35170
+ }
35171
+ if (entry.attribute !== void 0) {
35172
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35173
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35174
+ }
35175
+ }
35176
+ if (seen.has(entry.name)) {
35177
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35178
+ }
35179
+ seen.add(entry.name);
35180
+ for (const k of Object.keys(entry)) {
35181
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35182
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35183
+ }
35184
+ }
35185
+ }
35186
+ }
35187
+ function assertGraphqlOpsArray(value, label) {
35188
+ if (!Array.isArray(value)) {
35189
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35190
+ }
35191
+ const seen = /* @__PURE__ */ new Set();
35192
+ for (let i = 0; i < value.length; i++) {
35193
+ const entry = value[i];
35194
+ assertObject(entry, `${label}[${i}]`);
35195
+ if (entry.name === void 0) {
35196
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35197
+ }
35198
+ if (entry.operationName === void 0) {
35199
+ throw new ProtocolError(`${label}[${i}].operationName: missing`);
35200
+ }
35201
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35202
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35203
+ }
35204
+ if (typeof entry.operationName !== "string" || !GRAPHQL_OP_NAME_RE.test(entry.operationName)) {
35205
+ throw new ProtocolError(`${label}[${i}].operationName: invalid ${JSON.stringify(entry.operationName)}`);
35206
+ }
35207
+ if (seen.has(entry.name)) {
35208
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35209
+ }
35210
+ seen.add(entry.name);
35211
+ for (const k of Object.keys(entry)) {
35212
+ if (k !== "name" && k !== "operationName") {
35213
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35214
+ }
35215
+ }
35216
+ }
35217
+ }
35149
35218
  function validateFrame(raw) {
35150
35219
  assertObject(raw, "frame");
35151
35220
  const t = raw.type;
@@ -35221,6 +35290,12 @@ function validateHello(raw) {
35221
35290
  if (raw.sessionStoragePointers !== void 0) {
35222
35291
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35223
35292
  }
35293
+ if (raw.domSelectors !== void 0) {
35294
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35295
+ }
35296
+ if (raw.graphqlOps !== void 0) {
35297
+ assertGraphqlOpsArray(raw.graphqlOps, "hello.graphqlOps");
35298
+ }
35224
35299
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35225
35300
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35226
35301
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35455,6 +35530,43 @@ function validateInnerRequest(raw) {
35455
35530
  }
35456
35531
  return raw;
35457
35532
  }
35533
+ if (raw.op === "read_dom") {
35534
+ assertObject(raw.init, "inner.init");
35535
+ if (raw.init.origin === void 0)
35536
+ throw new ProtocolError("inner.init.origin: missing");
35537
+ if (raw.init.names === void 0)
35538
+ throw new ProtocolError("inner.init.names: missing");
35539
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35540
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35541
+ for (const k of Object.keys(raw.init)) {
35542
+ if (k !== "origin" && k !== "names") {
35543
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35544
+ }
35545
+ }
35546
+ return raw;
35547
+ }
35548
+ if (raw.op === "graphql_query") {
35549
+ assertObject(raw.init, "inner.init");
35550
+ if (raw.init.name === void 0)
35551
+ throw new ProtocolError("inner.init.name: missing");
35552
+ if (raw.init.variables === void 0) {
35553
+ throw new ProtocolError("inner.init.variables: missing");
35554
+ }
35555
+ assertString(raw.init.name, "inner.init.name");
35556
+ if (raw.init.name.length === 0) {
35557
+ throw new ProtocolError("inner.init.name: must be non-empty");
35558
+ }
35559
+ assertObject(raw.init.variables, "inner.init.variables");
35560
+ if (raw.init.tabUrl !== void 0) {
35561
+ assertString(raw.init.tabUrl, "inner.init.tabUrl");
35562
+ }
35563
+ for (const k of Object.keys(raw.init)) {
35564
+ if (k !== "name" && k !== "variables" && k !== "tabUrl") {
35565
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on graphql_query`);
35566
+ }
35567
+ }
35568
+ return raw;
35569
+ }
35458
35570
  if (raw.op === "download") {
35459
35571
  assertObject(raw.init, "inner.init");
35460
35572
  if (raw.init.url === void 0) {
@@ -35480,7 +35592,7 @@ function validateInnerRequest(raw) {
35480
35592
  }
35481
35593
  return raw;
35482
35594
  }
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)}`);
35595
+ 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", "graphql_query"; got ${JSON.stringify(raw.op)}`);
35484
35596
  }
35485
35597
  function assertNonEmptyKeyArray(value, label) {
35486
35598
  if (!Array.isArray(value)) {
@@ -35511,6 +35623,10 @@ function assertStringMap(value, label) {
35511
35623
  }
35512
35624
  }
35513
35625
  }
35626
+ var KNOWN_RESPONSE_OPS = /* @__PURE__ */ new Set([
35627
+ ...KNOWN_CAPABILITIES,
35628
+ "graphql_query"
35629
+ ]);
35514
35630
  function validateInnerResponse(raw) {
35515
35631
  assertPositiveInt(raw.id, "inner.id");
35516
35632
  if (raw.ok === true) {
@@ -35565,6 +35681,20 @@ function validateInnerResponse(raw) {
35565
35681
  assertObject(raw.values, "inner.values");
35566
35682
  return raw;
35567
35683
  }
35684
+ if (op === "read_dom") {
35685
+ if (raw.values === void 0) {
35686
+ throw new ProtocolError("inner.values: missing on read_dom response");
35687
+ }
35688
+ assertStringMap(raw.values, "inner.values");
35689
+ return raw;
35690
+ }
35691
+ if (op === "graphql_query") {
35692
+ if (raw.data === void 0) {
35693
+ throw new ProtocolError("inner.data: missing on graphql_query response");
35694
+ }
35695
+ assertObject(raw.data, "inner.data");
35696
+ return raw;
35697
+ }
35568
35698
  if (op === "download") {
35569
35699
  assertObject(raw.value, "inner.value");
35570
35700
  assertString(raw.value.path, "inner.value.path");
@@ -35589,7 +35719,7 @@ function validateInnerResponse(raw) {
35589
35719
  if (raw.ok === false) {
35590
35720
  assertString(raw.error, "inner.error");
35591
35721
  if (raw.op !== void 0) {
35592
- if (typeof raw.op !== "string" || !KNOWN_CAPABILITIES.has(raw.op)) {
35722
+ if (typeof raw.op !== "string" || !KNOWN_RESPONSE_OPS.has(raw.op)) {
35593
35723
  throw new ProtocolError(`inner.op: unknown response op ${JSON.stringify(raw.op)}`);
35594
35724
  }
35595
35725
  }
@@ -35781,11 +35911,33 @@ async function sealInnerFrame(sessionKey, mcpId, seq, inner) {
35781
35911
  };
35782
35912
  }
35783
35913
  async function openEncryptedFrame(sessionKey, frame) {
35784
- const iv = fromB64(frame.iv);
35785
- const ct = fromB64(frame.ciphertext);
35786
- const pt = await aesGcmOpen(sessionKey, iv, ct);
35787
- const parsed = JSON.parse(dec.decode(pt));
35788
- return validateInnerFrame(parsed);
35914
+ const result = await openEncryptedFrameDetailed(sessionKey, frame);
35915
+ if (result.stage === "ok")
35916
+ return result.inner;
35917
+ throw result.error instanceof Error ? result.error : new Error(String(result.error));
35918
+ }
35919
+ async function openEncryptedFrameDetailed(sessionKey, frame) {
35920
+ let pt;
35921
+ try {
35922
+ const iv = fromB64(frame.iv);
35923
+ const ct = fromB64(frame.ciphertext);
35924
+ pt = await aesGcmOpen(sessionKey, iv, ct);
35925
+ } catch (error51) {
35926
+ return { stage: "decrypt-failed", error: error51 };
35927
+ }
35928
+ let parsed;
35929
+ try {
35930
+ parsed = JSON.parse(dec.decode(pt));
35931
+ } catch (error51) {
35932
+ return { stage: "validation-failed", error: error51, recoveredId: void 0 };
35933
+ }
35934
+ try {
35935
+ const inner = validateInnerFrame(parsed);
35936
+ return { stage: "ok", inner };
35937
+ } catch (error51) {
35938
+ const recoveredId = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.id === "number" && Number.isInteger(parsed.id) && parsed.id > 0 ? parsed.id : void 0;
35939
+ return { stage: "validation-failed", error: error51, recoveredId };
35940
+ }
35789
35941
  }
35790
35942
 
35791
35943
  // node_modules/@fetchproxy/server/dist/election.js
@@ -35904,6 +36056,19 @@ async function buildServerHello(opts) {
35904
36056
  jsonPointer: d.jsonPointer
35905
36057
  }));
35906
36058
  }
36059
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36060
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36061
+ name: d.name,
36062
+ selector: d.selector,
36063
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36064
+ }));
36065
+ }
36066
+ if (opts.graphqlOps && opts.graphqlOps.length > 0) {
36067
+ hello.graphqlOps = opts.graphqlOps.map((d) => ({
36068
+ name: d.name,
36069
+ operationName: d.operationName
36070
+ }));
36071
+ }
35907
36072
  return hello;
35908
36073
  }
35909
36074
 
@@ -35993,7 +36158,9 @@ async function startHost(opts) {
35993
36158
  captureHeaders: opts.ownCaptureHeaders,
35994
36159
  indexedDbScopes: opts.ownIndexedDbScopes,
35995
36160
  localStoragePointers: opts.ownLocalStoragePointers,
35996
- sessionStoragePointers: opts.ownSessionStoragePointers
36161
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36162
+ domSelectors: opts.ownDomSelectors,
36163
+ graphqlOps: opts.ownGraphqlOps
35997
36164
  });
35998
36165
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
35999
36166
  let extensionWs = null;
@@ -36228,8 +36395,10 @@ async function startPeer(opts) {
36228
36395
  sessionStorageKeys: opts.sessionStorageKeys,
36229
36396
  captureHeaders: opts.captureHeaders,
36230
36397
  indexedDbScopes: opts.indexedDbScopes,
36398
+ domSelectors: opts.domSelectors,
36231
36399
  localStoragePointers: opts.localStoragePointers,
36232
- sessionStoragePointers: opts.sessionStoragePointers
36400
+ sessionStoragePointers: opts.sessionStoragePointers,
36401
+ graphqlOps: opts.graphqlOps
36233
36402
  });
36234
36403
  const sessionNonce = fromB64(hello.sessionNonce);
36235
36404
  ws.send(JSON.stringify(hello));
@@ -36273,10 +36442,20 @@ async function startPeer(opts) {
36273
36442
  return;
36274
36443
  if (!session.acceptInboundSeq(frame.seq))
36275
36444
  return;
36276
- try {
36277
- const inner = await openEncryptedFrame(session.sessionKey, frame);
36278
- innerListeners.forEach((cb) => cb(inner));
36279
- } catch {
36445
+ const result = await openEncryptedFrameDetailed(session.sessionKey, frame);
36446
+ if (result.stage === "ok") {
36447
+ innerListeners.forEach((cb) => cb(result.inner));
36448
+ } else if (result.stage === "decrypt-failed") {
36449
+ } else {
36450
+ console.error("[fetchproxy] peer: received a frame that decrypted OK but failed validation:", result.error);
36451
+ if (result.recoveredId !== void 0) {
36452
+ innerListeners.forEach((cb) => cb({
36453
+ type: "response",
36454
+ id: result.recoveredId,
36455
+ ok: false,
36456
+ error: `malformed response failed protocol validation: ${String(result.error)}`
36457
+ }));
36458
+ }
36280
36459
  }
36281
36460
  }
36282
36461
  } catch (e) {
@@ -36553,6 +36732,10 @@ var FetchproxyServer = class {
36553
36732
  pendingIdb = /* @__PURE__ */ new Map();
36554
36733
  // download awaiters resolve the saved-file metadata (path + size + mime).
36555
36734
  pendingDownload = /* @__PURE__ */ new Map();
36735
+ // 1.x+: graphql_query awaiters resolve the GraphQL `data` object. Its
36736
+ // shape is operation-specific, so the awaiter resolves `unknown` and the
36737
+ // caller narrows.
36738
+ pendingGraphql = /* @__PURE__ */ new Map();
36556
36739
  mcpId = null;
36557
36740
  identity = null;
36558
36741
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -36635,6 +36818,15 @@ var FetchproxyServer = class {
36635
36818
  key: d.key,
36636
36819
  jsonPointer: d.jsonPointer
36637
36820
  })),
36821
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36822
+ name: d.name,
36823
+ selector: d.selector,
36824
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36825
+ })),
36826
+ graphqlOps: (opts.graphqlOps ?? []).map((d) => ({
36827
+ name: d.name,
36828
+ operationName: d.operationName
36829
+ })),
36638
36830
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36639
36831
  // adapter was about to set these to the same numbers anyway; the
36640
36832
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36755,6 +36947,8 @@ var FetchproxyServer = class {
36755
36947
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36756
36948
  ownLocalStoragePointers: this.opts.localStoragePointers,
36757
36949
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36950
+ ownDomSelectors: this.opts.domSelectors,
36951
+ ownGraphqlOps: this.opts.graphqlOps,
36758
36952
  onPairCode: this.opts.onPairCode
36759
36953
  });
36760
36954
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36782,7 +36976,9 @@ var FetchproxyServer = class {
36782
36976
  captureHeaders: this.opts.captureHeaders,
36783
36977
  indexedDbScopes: this.opts.indexedDbScopes,
36784
36978
  localStoragePointers: this.opts.localStoragePointers,
36785
- sessionStoragePointers: this.opts.sessionStoragePointers
36979
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36980
+ domSelectors: this.opts.domSelectors,
36981
+ graphqlOps: this.opts.graphqlOps
36786
36982
  });
36787
36983
  this.peerHandle.onInner((inner) => this.onInner(inner));
36788
36984
  this.peerHandle.onRenegotiate(() => {
@@ -36988,6 +37184,7 @@ var FetchproxyServer = class {
36988
37184
  this.pendingRedirect.delete(id);
36989
37185
  this.pendingDownload.delete(id);
36990
37186
  this.pendingIdb.delete(id);
37187
+ this.pendingGraphql.delete(id);
36991
37188
  }
36992
37189
  throw err;
36993
37190
  }
@@ -37757,6 +37954,92 @@ var FetchproxyServer = class {
37757
37954
  await this.sendInnerFrame(inner);
37758
37955
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37759
37956
  }
37957
+ /**
37958
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37959
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37960
+ * match a declared `domSelectors` entry. The extension reads each
37961
+ * declared selector from the matched tab's DOM (isolated-world
37962
+ * `querySelector`, value or attribute) — no page-JS execution.
37963
+ *
37964
+ * Returns a `Record<string, string>` of `name → value`, with names
37965
+ * whose element (or attribute) was absent omitted. Throws
37966
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37967
+ * developer mistakes (undeclared capability, undeclared name).
37968
+ */
37969
+ async readDom(opts) {
37970
+ if (!this.opts.capabilities.includes("read_dom")) {
37971
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37972
+ }
37973
+ await this.ensureConnected();
37974
+ this.throwIfPendingPair();
37975
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37976
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37977
+ }
37978
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37979
+ if (opts.subdomain !== void 0)
37980
+ assertSubdomainLabel(opts.subdomain);
37981
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37982
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37983
+ const origin = `https://${host}`;
37984
+ const id = this.nextRequestId++;
37985
+ const inner = {
37986
+ type: "request",
37987
+ id,
37988
+ op: "read_dom",
37989
+ init: { origin, names: [...opts.names] }
37990
+ };
37991
+ const pending = new Promise((resolve, reject) => {
37992
+ this.pendingStorage.set(id, { resolve, reject });
37993
+ });
37994
+ await this.sendInnerFrame(inner);
37995
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37996
+ }
37997
+ /**
37998
+ * 1.x+: run a declared GraphQL operation through the page's own Apollo
37999
+ * client (`window.__APOLLO_CLIENT__`) in the signed-in tab's MAIN world.
38000
+ * Requires `'graphql'` in capabilities AND `name` to match a declared
38001
+ * `graphqlOps` entry. The extension resolves `name` → `operationName` →
38002
+ * the live DocumentNode the page already observed, then invokes
38003
+ * `client.query({ query, variables })` — the site's own request path, so
38004
+ * per-request bot telemetry (Akamai etc.) runs automatically.
38005
+ *
38006
+ * Returns the GraphQL `data` object on success (shape is
38007
+ * operation-specific; the caller narrows). Throws a plain `Error` on
38008
+ * developer mistakes (undeclared capability, undeclared name) and a
38009
+ * descriptive `Error` on the `ok:false` bridge path — which includes the
38010
+ * typed "operation not yet observed on this tab" case (open the site's
38011
+ * page and retry).
38012
+ */
38013
+ async graphqlQuery(opts) {
38014
+ if (!this.opts.capabilities.includes("graphql")) {
38015
+ throw new Error('FetchproxyServer.graphqlQuery(): MCP did not declare "graphql" in capabilities');
38016
+ }
38017
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
38018
+ throw new Error("FetchproxyServer.graphqlQuery: opts.name must be a non-empty string");
38019
+ }
38020
+ const declaredNames = this.opts.graphqlOps.map((d) => d.name);
38021
+ if (!declaredNames.includes(opts.name)) {
38022
+ throw new Error(`FetchproxyServer.graphqlQuery: operation ${JSON.stringify(opts.name)} not in declared graphqlOps [${declaredNames.map((n) => JSON.stringify(n)).join(", ")}]`);
38023
+ }
38024
+ await this.ensureConnected();
38025
+ this.throwIfPendingPair();
38026
+ const id = this.nextRequestId++;
38027
+ const inner = {
38028
+ type: "request",
38029
+ id,
38030
+ op: "graphql_query",
38031
+ init: {
38032
+ name: opts.name,
38033
+ variables: opts.variables,
38034
+ ...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
38035
+ }
38036
+ };
38037
+ const pending = new Promise((resolve, reject) => {
38038
+ this.pendingGraphql.set(id, { resolve, reject });
38039
+ });
38040
+ await this.sendInnerFrame(inner);
38041
+ return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
38042
+ }
37760
38043
  assertScopeSubset(requested, declared, label) {
37761
38044
  const undeclared = undeclaredKeys(requested, declared);
37762
38045
  if (undeclared.length > 0) {
@@ -37828,7 +38111,7 @@ var FetchproxyServer = class {
37828
38111
  if (storageCb) {
37829
38112
  this.pendingStorage.delete(inner.id);
37830
38113
  if (inner.ok) {
37831
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38114
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37832
38115
  storageCb.resolve({ ...inner.values });
37833
38116
  } else {
37834
38117
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -37894,6 +38177,20 @@ var FetchproxyServer = class {
37894
38177
  }
37895
38178
  return;
37896
38179
  }
38180
+ const graphqlCb = this.pendingGraphql.get(inner.id);
38181
+ if (graphqlCb) {
38182
+ this.pendingGraphql.delete(inner.id);
38183
+ if (inner.ok) {
38184
+ if (inner.op === "graphql_query") {
38185
+ graphqlCb.resolve(inner.data);
38186
+ } else {
38187
+ graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
38188
+ }
38189
+ } else {
38190
+ graphqlCb.reject(new FetchproxyProtocolError(inner.error));
38191
+ }
38192
+ return;
38193
+ }
37897
38194
  const cookiesCb = this.pendingReadCookies.get(inner.id);
37898
38195
  if (cookiesCb) {
37899
38196
  this.pendingReadCookies.delete(inner.id);
@@ -37945,6 +38242,9 @@ var FetchproxyServer = class {
37945
38242
  for (const { reject } of this.pendingDownload.values())
37946
38243
  reject(err);
37947
38244
  this.pendingDownload.clear();
38245
+ for (const { reject } of this.pendingGraphql.values())
38246
+ reject(err);
38247
+ this.pendingGraphql.clear();
37948
38248
  }
37949
38249
  /**
37950
38250
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -38191,7 +38491,7 @@ function loadAccount(env = process.env) {
38191
38491
  // package.json
38192
38492
  var package_default = {
38193
38493
  name: "infinitecampus-mcp",
38194
- version: "2.3.6",
38494
+ version: "2.4.2",
38195
38495
  mcpName: "io.github.chrischall/infinitecampus-mcp",
38196
38496
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
38197
38497
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38233,19 +38533,19 @@ var package_default = {
38233
38533
  "test:watch": "vitest"
38234
38534
  },
38235
38535
  dependencies: {
38236
- "@chrischall/mcp-utils": "^0.12.0",
38237
- "@fetchproxy/bootstrap": "^1.0.0",
38238
- "@fetchproxy/server": "^1.0.0",
38536
+ "@chrischall/mcp-utils": "^0.14.0",
38537
+ "@fetchproxy/bootstrap": "^1.7.0",
38538
+ "@fetchproxy/server": "^1.7.0",
38239
38539
  "@modelcontextprotocol/sdk": "^1.29.0",
38240
38540
  dotenv: "^17.4.2",
38241
- "node-html-parser": "^8.0.2",
38541
+ "node-html-parser": "^9.0.0",
38242
38542
  zod: "^4.4.3"
38243
38543
  },
38244
38544
  devDependencies: {
38245
38545
  "@types/node": "^26.0.0",
38246
38546
  "@vitest/coverage-v8": "^4.1.7",
38247
38547
  esbuild: "^0.28.0",
38248
- typescript: "^6.0.3",
38548
+ typescript: "^7.0.2",
38249
38549
  vitest: "^4.1.7"
38250
38550
  }
38251
38551
  };
@@ -39807,7 +40107,7 @@ try {
39807
40107
  }
39808
40108
  var COMMON = {
39809
40109
  name: "infinitecampus",
39810
- version: "2.3.6"
40110
+ version: "2.4.2"
39811
40111
  // x-release-please-version
39812
40112
  };
39813
40113
  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.2', // 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.2",
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",
46
- "@fetchproxy/bootstrap": "^1.0.0",
47
- "@fetchproxy/server": "^1.0.0",
45
+ "@chrischall/mcp-utils": "^0.14.0",
46
+ "@fetchproxy/bootstrap": "^1.7.0",
47
+ "@fetchproxy/server": "^1.7.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.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.3.6",
14
+ "version": "2.4.2",
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.