infinitecampus-mcp 2.3.2 → 2.3.4
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 +1 -1
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/dist/bundle.js +330 -13
- package/dist/client.js +56 -16
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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.
|
|
10
|
+
"version": "2.3.4"
|
|
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.
|
|
18
|
+
"version": "2.3.4",
|
|
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.
|
|
4
|
+
"version": "2.3.4",
|
|
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",
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Hall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# infinitecampus-mcp
|
|
2
2
|
|
|
3
|
+
[](https://github.com/chrischall/infinitecampus-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/infinitecampus-mcp)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
MCP server for Infinite Campus (Campus Parent portal). Single-account config — linked districts are auto-discovered via CUPS SSO after login.
|
|
4
8
|
|
|
5
9
|
## Tools
|
package/dist/bundle.js
CHANGED
|
@@ -34791,7 +34791,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
34791
34791
|
"read_local_storage",
|
|
34792
34792
|
"read_session_storage",
|
|
34793
34793
|
"capture_request_header",
|
|
34794
|
-
"
|
|
34794
|
+
"capture_redirect",
|
|
34795
|
+
"read_indexed_db",
|
|
34796
|
+
"download"
|
|
34795
34797
|
]);
|
|
34796
34798
|
|
|
34797
34799
|
// node_modules/@fetchproxy/protocol/dist/mcp-id.js
|
|
@@ -35351,6 +35353,31 @@ function validateInnerRequest(raw) {
|
|
|
35351
35353
|
}
|
|
35352
35354
|
return raw;
|
|
35353
35355
|
}
|
|
35356
|
+
if (raw.op === "capture_redirect") {
|
|
35357
|
+
assertObject(raw.init, "inner.init");
|
|
35358
|
+
if (raw.init.host === void 0) {
|
|
35359
|
+
throw new ProtocolError("inner.init.host: missing");
|
|
35360
|
+
}
|
|
35361
|
+
assertString(raw.init.host, "inner.init.host");
|
|
35362
|
+
if (!HOSTNAME_RE.test(raw.init.host)) {
|
|
35363
|
+
throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
|
|
35364
|
+
}
|
|
35365
|
+
if (raw.init.path !== void 0) {
|
|
35366
|
+
assertString(raw.init.path, "inner.init.path");
|
|
35367
|
+
if (!CAPTURE_PATH_RE.test(raw.init.path)) {
|
|
35368
|
+
throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
|
|
35369
|
+
}
|
|
35370
|
+
}
|
|
35371
|
+
if (raw.init.timeoutMs !== void 0) {
|
|
35372
|
+
assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
|
|
35373
|
+
}
|
|
35374
|
+
for (const k of Object.keys(raw.init)) {
|
|
35375
|
+
if (k !== "host" && k !== "path" && k !== "timeoutMs") {
|
|
35376
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_redirect`);
|
|
35377
|
+
}
|
|
35378
|
+
}
|
|
35379
|
+
return raw;
|
|
35380
|
+
}
|
|
35354
35381
|
if (raw.op === "read_indexed_db") {
|
|
35355
35382
|
assertObject(raw.init, "inner.init");
|
|
35356
35383
|
if (raw.init.origin === void 0)
|
|
@@ -35376,7 +35403,32 @@ function validateInnerRequest(raw) {
|
|
|
35376
35403
|
}
|
|
35377
35404
|
return raw;
|
|
35378
35405
|
}
|
|
35379
|
-
|
|
35406
|
+
if (raw.op === "download") {
|
|
35407
|
+
assertObject(raw.init, "inner.init");
|
|
35408
|
+
if (raw.init.url === void 0) {
|
|
35409
|
+
throw new ProtocolError("inner.init.url: missing");
|
|
35410
|
+
}
|
|
35411
|
+
assertHttpUrl(raw.init.url, "inner.init.url");
|
|
35412
|
+
if (raw.init.filename !== void 0) {
|
|
35413
|
+
assertString(raw.init.filename, "inner.init.filename");
|
|
35414
|
+
if (/^([/\\]|[A-Za-z]:)/.test(raw.init.filename)) {
|
|
35415
|
+
throw new ProtocolError(`inner.init.filename: must be relative ${JSON.stringify(raw.init.filename)}`);
|
|
35416
|
+
}
|
|
35417
|
+
if (raw.init.filename.split(/[/\\]/).includes("..")) {
|
|
35418
|
+
throw new ProtocolError(`inner.init.filename: must not contain '..' ${JSON.stringify(raw.init.filename)}`);
|
|
35419
|
+
}
|
|
35420
|
+
}
|
|
35421
|
+
if (raw.init.timeoutMs !== void 0) {
|
|
35422
|
+
assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
|
|
35423
|
+
}
|
|
35424
|
+
for (const k of Object.keys(raw.init)) {
|
|
35425
|
+
if (k !== "url" && k !== "filename" && k !== "timeoutMs") {
|
|
35426
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on download`);
|
|
35427
|
+
}
|
|
35428
|
+
}
|
|
35429
|
+
return raw;
|
|
35430
|
+
}
|
|
35431
|
+
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)}`);
|
|
35380
35432
|
}
|
|
35381
35433
|
function assertNonEmptyKeyArray(value, label) {
|
|
35382
35434
|
if (!Array.isArray(value)) {
|
|
@@ -35447,6 +35499,13 @@ function validateInnerResponse(raw) {
|
|
|
35447
35499
|
assertString(raw.value, "inner.value");
|
|
35448
35500
|
return raw;
|
|
35449
35501
|
}
|
|
35502
|
+
if (op === "capture_redirect") {
|
|
35503
|
+
if (raw.value === void 0) {
|
|
35504
|
+
throw new ProtocolError("inner.value: missing on capture_redirect response");
|
|
35505
|
+
}
|
|
35506
|
+
assertString(raw.value, "inner.value");
|
|
35507
|
+
return raw;
|
|
35508
|
+
}
|
|
35450
35509
|
if (op === "read_indexed_db") {
|
|
35451
35510
|
if (raw.values === void 0) {
|
|
35452
35511
|
throw new ProtocolError("inner.values: missing on read_indexed_db response");
|
|
@@ -35454,6 +35513,25 @@ function validateInnerResponse(raw) {
|
|
|
35454
35513
|
assertObject(raw.values, "inner.values");
|
|
35455
35514
|
return raw;
|
|
35456
35515
|
}
|
|
35516
|
+
if (op === "download") {
|
|
35517
|
+
assertObject(raw.value, "inner.value");
|
|
35518
|
+
assertString(raw.value.path, "inner.value.path");
|
|
35519
|
+
if (typeof raw.value.bytes !== "number" || !Number.isInteger(raw.value.bytes) || raw.value.bytes < 0) {
|
|
35520
|
+
throw new ProtocolError("inner.value.bytes: expected non-negative integer");
|
|
35521
|
+
}
|
|
35522
|
+
if (raw.value.mime !== void 0) {
|
|
35523
|
+
assertString(raw.value.mime, "inner.value.mime");
|
|
35524
|
+
}
|
|
35525
|
+
if (raw.value.finalUrl !== void 0) {
|
|
35526
|
+
assertString(raw.value.finalUrl, "inner.value.finalUrl");
|
|
35527
|
+
}
|
|
35528
|
+
for (const k of Object.keys(raw.value)) {
|
|
35529
|
+
if (k !== "path" && k !== "bytes" && k !== "mime" && k !== "finalUrl") {
|
|
35530
|
+
throw new ProtocolError(`inner.value: unexpected field ${JSON.stringify(k)} on download response`);
|
|
35531
|
+
}
|
|
35532
|
+
}
|
|
35533
|
+
return raw;
|
|
35534
|
+
}
|
|
35457
35535
|
throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
|
|
35458
35536
|
}
|
|
35459
35537
|
if (raw.ok === false) {
|
|
@@ -36324,8 +36402,12 @@ var FetchproxyServer = class {
|
|
|
36324
36402
|
pendingStorage = /* @__PURE__ */ new Map();
|
|
36325
36403
|
// 0.3.0+: capture-header awaiters resolve a single string.
|
|
36326
36404
|
pendingCapture = /* @__PURE__ */ new Map();
|
|
36405
|
+
// capture_redirect awaiters resolve the captured redirect URL string.
|
|
36406
|
+
pendingRedirect = /* @__PURE__ */ new Map();
|
|
36327
36407
|
// 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
|
|
36328
36408
|
pendingIdb = /* @__PURE__ */ new Map();
|
|
36409
|
+
// download awaiters resolve the saved-file metadata (path + size + mime).
|
|
36410
|
+
pendingDownload = /* @__PURE__ */ new Map();
|
|
36329
36411
|
mcpId = null;
|
|
36330
36412
|
identity = null;
|
|
36331
36413
|
// 0.5.3+: in-flight role-election / handle-start promise. Set the
|
|
@@ -37257,6 +37339,181 @@ var FetchproxyServer = class {
|
|
|
37257
37339
|
}
|
|
37258
37340
|
return pending;
|
|
37259
37341
|
}
|
|
37342
|
+
/**
|
|
37343
|
+
* Snapshot the redirect target URL of the next request the browser
|
|
37344
|
+
* makes to `(host, path?)`. Single-shot: the extension registers a
|
|
37345
|
+
* one-time `chrome.webRequest.onBeforeRedirect` listener filtered on
|
|
37346
|
+
* `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on
|
|
37347
|
+
* the first match, removes itself, and resolves with the URL. Times out
|
|
37348
|
+
* after `timeoutMs` (default 30s on the extension).
|
|
37349
|
+
*
|
|
37350
|
+
* Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin
|
|
37351
|
+
* to a presigned URL — a page-level fetch sees only an opaque redirect,
|
|
37352
|
+
* but `onBeforeRedirect` exposes the target. Capture is limited to the
|
|
37353
|
+
* MCP's own declared `domains`; no per-entry declared scope is required.
|
|
37354
|
+
*/
|
|
37355
|
+
async captureRedirect(opts) {
|
|
37356
|
+
if (!this.opts.capabilities.includes("capture_redirect")) {
|
|
37357
|
+
throw new Error('FetchproxyServer.captureRedirect(): MCP did not declare "capture_redirect" in capabilities');
|
|
37358
|
+
}
|
|
37359
|
+
await this.ensureConnected();
|
|
37360
|
+
this.throwIfPendingPair();
|
|
37361
|
+
try {
|
|
37362
|
+
const result = await this._captureRedirectOnce(opts);
|
|
37363
|
+
this.recordSuccess();
|
|
37364
|
+
return result;
|
|
37365
|
+
} catch (err) {
|
|
37366
|
+
const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
|
|
37367
|
+
if (!swDown) {
|
|
37368
|
+
this.recordFailure(`capture_redirect: ${err.message ?? String(err)}`);
|
|
37369
|
+
throw err;
|
|
37370
|
+
}
|
|
37371
|
+
this.lastEvictionDetectedAt = Date.now();
|
|
37372
|
+
const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
|
|
37373
|
+
if (reviveMs > 0) {
|
|
37374
|
+
this.lazyReviveAttempts += 1;
|
|
37375
|
+
await new Promise((r) => setTimeout(r, reviveMs));
|
|
37376
|
+
try {
|
|
37377
|
+
const result = await this._captureRedirectOnce(opts);
|
|
37378
|
+
this.lazyReviveSuccesses += 1;
|
|
37379
|
+
this.recordSuccess();
|
|
37380
|
+
return result;
|
|
37381
|
+
} catch (retryErr) {
|
|
37382
|
+
const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
|
|
37383
|
+
if (!stillDown) {
|
|
37384
|
+
this.recordFailure(`capture_redirect: ${retryErr.message ?? String(retryErr)}`);
|
|
37385
|
+
throw retryErr;
|
|
37386
|
+
}
|
|
37387
|
+
this.recordFailure(`capture_redirect bridge-down: ${retryErr.message}`);
|
|
37388
|
+
throw new FetchproxyBridgeDownError({
|
|
37389
|
+
originalError: retryErr.message,
|
|
37390
|
+
retryAttempted: true,
|
|
37391
|
+
op: "capture_redirect",
|
|
37392
|
+
url: `https://${opts.host}${opts.path ?? "/*"}`,
|
|
37393
|
+
role: this.role,
|
|
37394
|
+
port: this.opts.port
|
|
37395
|
+
});
|
|
37396
|
+
}
|
|
37397
|
+
}
|
|
37398
|
+
this.recordFailure(`capture_redirect bridge-down: ${err.message}`);
|
|
37399
|
+
throw new FetchproxyBridgeDownError({
|
|
37400
|
+
originalError: err.message,
|
|
37401
|
+
retryAttempted: false,
|
|
37402
|
+
op: "capture_redirect",
|
|
37403
|
+
url: `https://${opts.host}${opts.path ?? "/*"}`,
|
|
37404
|
+
role: this.role,
|
|
37405
|
+
port: this.opts.port
|
|
37406
|
+
});
|
|
37407
|
+
}
|
|
37408
|
+
}
|
|
37409
|
+
async _captureRedirectOnce(opts) {
|
|
37410
|
+
const id = this.nextRequestId++;
|
|
37411
|
+
const inner = {
|
|
37412
|
+
type: "request",
|
|
37413
|
+
id,
|
|
37414
|
+
op: "capture_redirect",
|
|
37415
|
+
init: {
|
|
37416
|
+
host: opts.host,
|
|
37417
|
+
...opts.path !== void 0 ? { path: opts.path } : {},
|
|
37418
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
|
|
37419
|
+
}
|
|
37420
|
+
};
|
|
37421
|
+
const pending = new Promise((resolve, reject) => {
|
|
37422
|
+
this.pendingRedirect.set(id, { resolve, reject });
|
|
37423
|
+
});
|
|
37424
|
+
if (this.hostHandle) {
|
|
37425
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37426
|
+
} else if (this.peerHandle) {
|
|
37427
|
+
await this.peerHandle.sendInner(inner);
|
|
37428
|
+
}
|
|
37429
|
+
return pending;
|
|
37430
|
+
}
|
|
37431
|
+
/**
|
|
37432
|
+
* Download `url` through the BROWSER's own network stack via
|
|
37433
|
+
* `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a
|
|
37434
|
+
* page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge
|
|
37435
|
+
* on the endpoint and follows the cross-origin redirect to the final file.
|
|
37436
|
+
* Resolves the saved local file path + size; the bridge is loopback-only /
|
|
37437
|
+
* single-host, so the MCP reads the bytes from the same disk. Requires
|
|
37438
|
+
* `'download'` in capabilities and the `url` host to be a declared `domain`.
|
|
37439
|
+
*/
|
|
37440
|
+
async download(opts) {
|
|
37441
|
+
if (!this.opts.capabilities.includes("download")) {
|
|
37442
|
+
throw new Error('FetchproxyServer.download(): MCP did not declare "download" in capabilities');
|
|
37443
|
+
}
|
|
37444
|
+
assertUrlInDomains("download url", opts.url, this.opts.domains);
|
|
37445
|
+
await this.ensureConnected();
|
|
37446
|
+
this.throwIfPendingPair();
|
|
37447
|
+
try {
|
|
37448
|
+
const result = await this._downloadOnce(opts);
|
|
37449
|
+
this.recordSuccess();
|
|
37450
|
+
return result;
|
|
37451
|
+
} catch (err) {
|
|
37452
|
+
const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
|
|
37453
|
+
if (!swDown) {
|
|
37454
|
+
this.recordFailure(`download: ${err.message ?? String(err)}`);
|
|
37455
|
+
throw err;
|
|
37456
|
+
}
|
|
37457
|
+
this.lastEvictionDetectedAt = Date.now();
|
|
37458
|
+
const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
|
|
37459
|
+
if (reviveMs > 0) {
|
|
37460
|
+
this.lazyReviveAttempts += 1;
|
|
37461
|
+
await new Promise((r) => setTimeout(r, reviveMs));
|
|
37462
|
+
try {
|
|
37463
|
+
const result = await this._downloadOnce(opts);
|
|
37464
|
+
this.lazyReviveSuccesses += 1;
|
|
37465
|
+
this.recordSuccess();
|
|
37466
|
+
return result;
|
|
37467
|
+
} catch (retryErr) {
|
|
37468
|
+
const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
|
|
37469
|
+
if (!stillDown) {
|
|
37470
|
+
this.recordFailure(`download: ${retryErr.message ?? String(retryErr)}`);
|
|
37471
|
+
throw retryErr;
|
|
37472
|
+
}
|
|
37473
|
+
this.recordFailure(`download bridge-down: ${retryErr.message}`);
|
|
37474
|
+
throw new FetchproxyBridgeDownError({
|
|
37475
|
+
originalError: retryErr.message,
|
|
37476
|
+
retryAttempted: true,
|
|
37477
|
+
op: "download",
|
|
37478
|
+
url: opts.url,
|
|
37479
|
+
role: this.role,
|
|
37480
|
+
port: this.opts.port
|
|
37481
|
+
});
|
|
37482
|
+
}
|
|
37483
|
+
}
|
|
37484
|
+
this.recordFailure(`download bridge-down: ${err.message}`);
|
|
37485
|
+
throw new FetchproxyBridgeDownError({
|
|
37486
|
+
originalError: err.message,
|
|
37487
|
+
retryAttempted: false,
|
|
37488
|
+
op: "download",
|
|
37489
|
+
url: opts.url,
|
|
37490
|
+
role: this.role,
|
|
37491
|
+
port: this.opts.port
|
|
37492
|
+
});
|
|
37493
|
+
}
|
|
37494
|
+
}
|
|
37495
|
+
async _downloadOnce(opts) {
|
|
37496
|
+
const id = this.nextRequestId++;
|
|
37497
|
+
const inner = {
|
|
37498
|
+
type: "request",
|
|
37499
|
+
id,
|
|
37500
|
+
op: "download",
|
|
37501
|
+
init: {
|
|
37502
|
+
url: opts.url,
|
|
37503
|
+
...opts.filename !== void 0 ? { filename: opts.filename } : {},
|
|
37504
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
|
|
37505
|
+
}
|
|
37506
|
+
};
|
|
37507
|
+
const pending = new Promise((resolve, reject) => {
|
|
37508
|
+
this.pendingDownload.set(id, { resolve, reject });
|
|
37509
|
+
});
|
|
37510
|
+
if (this.hostHandle) {
|
|
37511
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37512
|
+
} else if (this.peerHandle) {
|
|
37513
|
+
await this.peerHandle.sendInner(inner);
|
|
37514
|
+
}
|
|
37515
|
+
return pending;
|
|
37516
|
+
}
|
|
37260
37517
|
/**
|
|
37261
37518
|
* 0.4.0+: read declared IndexedDB keys from the user's signed-in
|
|
37262
37519
|
* tab. Requires `'read_indexed_db'` in capabilities AND the
|
|
@@ -37404,6 +37661,20 @@ var FetchproxyServer = class {
|
|
|
37404
37661
|
}
|
|
37405
37662
|
return;
|
|
37406
37663
|
}
|
|
37664
|
+
const redirectCb = this.pendingRedirect.get(inner.id);
|
|
37665
|
+
if (redirectCb) {
|
|
37666
|
+
this.pendingRedirect.delete(inner.id);
|
|
37667
|
+
if (inner.ok) {
|
|
37668
|
+
if (inner.op === "capture_redirect" && typeof inner.value === "string") {
|
|
37669
|
+
redirectCb.resolve(inner.value);
|
|
37670
|
+
} else {
|
|
37671
|
+
redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
|
|
37672
|
+
}
|
|
37673
|
+
} else {
|
|
37674
|
+
redirectCb.reject(new FetchproxyProtocolError(inner.error));
|
|
37675
|
+
}
|
|
37676
|
+
return;
|
|
37677
|
+
}
|
|
37407
37678
|
const idbCb = this.pendingIdb.get(inner.id);
|
|
37408
37679
|
if (idbCb) {
|
|
37409
37680
|
this.pendingIdb.delete(inner.id);
|
|
@@ -37418,6 +37689,20 @@ var FetchproxyServer = class {
|
|
|
37418
37689
|
}
|
|
37419
37690
|
return;
|
|
37420
37691
|
}
|
|
37692
|
+
const downloadCb = this.pendingDownload.get(inner.id);
|
|
37693
|
+
if (downloadCb) {
|
|
37694
|
+
this.pendingDownload.delete(inner.id);
|
|
37695
|
+
if (inner.ok) {
|
|
37696
|
+
if (inner.op === "download" && inner.value && typeof inner.value === "object") {
|
|
37697
|
+
downloadCb.resolve({ ...inner.value });
|
|
37698
|
+
} else {
|
|
37699
|
+
downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
|
|
37700
|
+
}
|
|
37701
|
+
} else {
|
|
37702
|
+
downloadCb.reject(new FetchproxyProtocolError(inner.error));
|
|
37703
|
+
}
|
|
37704
|
+
return;
|
|
37705
|
+
}
|
|
37421
37706
|
const cookiesCb = this.pendingReadCookies.get(inner.id);
|
|
37422
37707
|
if (cookiesCb) {
|
|
37423
37708
|
this.pendingReadCookies.delete(inner.id);
|
|
@@ -37460,9 +37745,15 @@ var FetchproxyServer = class {
|
|
|
37460
37745
|
for (const { reject } of this.pendingCapture.values())
|
|
37461
37746
|
reject(err);
|
|
37462
37747
|
this.pendingCapture.clear();
|
|
37748
|
+
for (const { reject } of this.pendingRedirect.values())
|
|
37749
|
+
reject(err);
|
|
37750
|
+
this.pendingRedirect.clear();
|
|
37463
37751
|
for (const { reject } of this.pendingIdb.values())
|
|
37464
37752
|
reject(err);
|
|
37465
37753
|
this.pendingIdb.clear();
|
|
37754
|
+
for (const { reject } of this.pendingDownload.values())
|
|
37755
|
+
reject(err);
|
|
37756
|
+
this.pendingDownload.clear();
|
|
37466
37757
|
}
|
|
37467
37758
|
/**
|
|
37468
37759
|
* 0.5.2+: read the current pair-pending pair code from whichever handle
|
|
@@ -37709,7 +38000,7 @@ function loadAccount(env = process.env) {
|
|
|
37709
38000
|
// package.json
|
|
37710
38001
|
var package_default = {
|
|
37711
38002
|
name: "infinitecampus-mcp",
|
|
37712
|
-
version: "2.3.
|
|
38003
|
+
version: "2.3.4",
|
|
37713
38004
|
mcpName: "io.github.chrischall/infinitecampus-mcp",
|
|
37714
38005
|
description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
|
|
37715
38006
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -37941,6 +38232,20 @@ var ICClient = class {
|
|
|
37941
38232
|
}
|
|
37942
38233
|
}
|
|
37943
38234
|
async login(account2) {
|
|
38235
|
+
const primaryName = this.linkedTo.get(account2.name);
|
|
38236
|
+
if (primaryName) {
|
|
38237
|
+
this.sessions.delete(account2.name);
|
|
38238
|
+
const primaryAccount = this.accounts.get(primaryName);
|
|
38239
|
+
await this.ensureSession(primaryAccount);
|
|
38240
|
+
if (!this.sessions.has(account2.name)) {
|
|
38241
|
+
throw new AuthFailedError(
|
|
38242
|
+
account2.name,
|
|
38243
|
+
"linked-district session could not be re-established via the primary district",
|
|
38244
|
+
{ credentialHint: false }
|
|
38245
|
+
);
|
|
38246
|
+
}
|
|
38247
|
+
return;
|
|
38248
|
+
}
|
|
37944
38249
|
if (!account2.username || !account2.password) {
|
|
37945
38250
|
throw new AuthFailedError(
|
|
37946
38251
|
account2.name,
|
|
@@ -37948,8 +38253,17 @@ var ICClient = class {
|
|
|
37948
38253
|
);
|
|
37949
38254
|
}
|
|
37950
38255
|
const postRes = await fetch(
|
|
37951
|
-
`${account2.baseUrl}/campus/verify.jsp?nonBrowser=true
|
|
37952
|
-
{
|
|
38256
|
+
`${account2.baseUrl}/campus/verify.jsp?nonBrowser=true`,
|
|
38257
|
+
{
|
|
38258
|
+
method: "POST",
|
|
38259
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
38260
|
+
body: new URLSearchParams({
|
|
38261
|
+
username: account2.username,
|
|
38262
|
+
password: account2.password,
|
|
38263
|
+
appName: account2.district,
|
|
38264
|
+
portalLoginPage: "parents"
|
|
38265
|
+
}).toString()
|
|
38266
|
+
}
|
|
37953
38267
|
);
|
|
37954
38268
|
if (postRes.status >= 500) throw new PortalUnreachableError(account2.name, postRes.status);
|
|
37955
38269
|
const body = await postRes.text();
|
|
@@ -37972,9 +38286,7 @@ var ICClient = class {
|
|
|
37972
38286
|
session.cookie = cookies.cookieHeader;
|
|
37973
38287
|
session.xsrfToken = cookies.xsrfToken;
|
|
37974
38288
|
session.loggedInAt = Date.now();
|
|
37975
|
-
|
|
37976
|
-
await this.discoverLinkedDistricts(account2);
|
|
37977
|
-
}
|
|
38289
|
+
await this.discoverLinkedDistricts(account2);
|
|
37978
38290
|
}
|
|
37979
38291
|
async discoverLinkedDistricts(account2) {
|
|
37980
38292
|
try {
|
|
@@ -38157,11 +38469,16 @@ var UnknownDistrictError = class extends Error {
|
|
|
38157
38469
|
available;
|
|
38158
38470
|
};
|
|
38159
38471
|
var AuthFailedError = class extends Error {
|
|
38160
|
-
|
|
38472
|
+
/**
|
|
38473
|
+
* @param opts.credentialHint When `false`, the message omits the
|
|
38474
|
+
* "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
|
|
38475
|
+
* credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
|
|
38476
|
+
* failure) and pointing the user at their creds would be misleading.
|
|
38477
|
+
*/
|
|
38478
|
+
constructor(district, reason, opts) {
|
|
38161
38479
|
const detail = reason ? ` (${reason})` : "";
|
|
38162
|
-
|
|
38163
|
-
|
|
38164
|
-
);
|
|
38480
|
+
const remedy = opts?.credentialHint === false ? "Sign in again at the IC portal in your browser, then restart the MCP." : "Check IC_USERNAME and IC_PASSWORD; if those are correct, the account may be locked or the portal may be down.";
|
|
38481
|
+
super(`Login failed for district '${district}'${detail}. ${remedy}`);
|
|
38165
38482
|
this.district = district;
|
|
38166
38483
|
this.reason = reason;
|
|
38167
38484
|
this.name = "AuthFailedError";
|
|
@@ -39086,7 +39403,7 @@ try {
|
|
|
39086
39403
|
}
|
|
39087
39404
|
var COMMON = {
|
|
39088
39405
|
name: "infinitecampus",
|
|
39089
|
-
version: "2.3.
|
|
39406
|
+
version: "2.3.4"
|
|
39090
39407
|
// x-release-please-version
|
|
39091
39408
|
};
|
|
39092
39409
|
if (account) {
|
package/dist/client.js
CHANGED
|
@@ -44,10 +44,9 @@ export class ICClient {
|
|
|
44
44
|
await this.ensureSession(this.accounts.get(this.primaryName));
|
|
45
45
|
// fetchproxy mode skips login() and therefore the discovery call inside
|
|
46
46
|
// it. Run discovery directly the first time someone asks for it. The
|
|
47
|
-
// primary is never in linkedTo (it's the root of the linkedTo map)
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// district during a TTL refresh.
|
|
47
|
+
// primary is never in linkedTo (it's the root of the linkedTo map). When
|
|
48
|
+
// login() is called for an already-linked district during a TTL refresh,
|
|
49
|
+
// it re-auths through the primary instead of running discovery itself.
|
|
51
50
|
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
|
|
52
51
|
this.fetchproxyDiscoveryRan = true;
|
|
53
52
|
await this.discoverLinkedDistricts(this.accounts.get(this.primaryName));
|
|
@@ -119,18 +118,50 @@ export class ICClient {
|
|
|
119
118
|
}
|
|
120
119
|
}
|
|
121
120
|
async login(account) {
|
|
121
|
+
// Linked districts have no real credentials of their own — they hold
|
|
122
|
+
// synthetic '(linked)' placeholders and are authenticated by the primary's
|
|
123
|
+
// CUPS SSO switch, not by their own verify.jsp. When a linked-district
|
|
124
|
+
// session expires by TTL (vs. a 401, which doRequest already routes through
|
|
125
|
+
// the primary), ensureSession lands here. Re-login the primary, which
|
|
126
|
+
// re-runs discoverLinkedDistricts and re-establishes this district's
|
|
127
|
+
// session — POSTing the placeholder creds to verify.jsp would instead yield
|
|
128
|
+
// a misleading password-error. See the comment block at the top of the file.
|
|
129
|
+
const primaryName = this.linkedTo.get(account.name);
|
|
130
|
+
if (primaryName) {
|
|
131
|
+
// Drop the stale linked session so re-discovery installs a fresh one.
|
|
132
|
+
this.sessions.delete(account.name);
|
|
133
|
+
const primaryAccount = this.accounts.get(primaryName);
|
|
134
|
+
await this.ensureSession(primaryAccount);
|
|
135
|
+
// ensureSession(primary) re-ran login() → discoverLinkedDistricts(),
|
|
136
|
+
// which re-creates the linked session. If discovery failed to restore it,
|
|
137
|
+
// surface a clear error rather than leaving callers with a null session.
|
|
138
|
+
if (!this.sessions.has(account.name)) {
|
|
139
|
+
throw new AuthFailedError(account.name, 'linked-district session could not be re-established via the primary district', { credentialHint: false });
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
122
143
|
// fetchproxy mode: empty primary creds, can't post to verify.jsp.
|
|
123
144
|
// The user must re-sign-in in the browser (and the next process start
|
|
124
|
-
// will pick up the fresh cookies).
|
|
125
|
-
// creds too; their re-auth flows through the primary via CUPS, not
|
|
126
|
-
// verify.jsp directly.
|
|
145
|
+
// will pick up the fresh cookies).
|
|
127
146
|
if (!account.username || !account.password) {
|
|
128
147
|
throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
|
|
129
148
|
'sign back into your IC portal in the browser and restart the MCP');
|
|
130
149
|
}
|
|
131
150
|
// ic_parent_api's pattern: single POST to verify.jsp, let the response
|
|
132
151
|
// set cookies. No pre-login GET needed (unlike OFW's Spring Security).
|
|
133
|
-
|
|
152
|
+
// Credentials go in the urlencoded form body, NOT the URL query string —
|
|
153
|
+
// query-string creds land in proxy/LB/server access logs even over HTTPS.
|
|
154
|
+
// Mirrors the CUPS switch POST body construction against the same host.
|
|
155
|
+
const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
158
|
+
body: new URLSearchParams({
|
|
159
|
+
username: account.username,
|
|
160
|
+
password: account.password,
|
|
161
|
+
appName: account.district,
|
|
162
|
+
portalLoginPage: 'parents',
|
|
163
|
+
}).toString(),
|
|
164
|
+
});
|
|
134
165
|
if (postRes.status >= 500)
|
|
135
166
|
throw new PortalUnreachableError(account.name, postRes.status);
|
|
136
167
|
// IC's verify.jsp returns 200 with an <AUTHENTICATION>X</AUTHENTICATION>
|
|
@@ -160,10 +191,11 @@ export class ICClient {
|
|
|
160
191
|
session.cookie = cookies.cookieHeader;
|
|
161
192
|
session.xsrfToken = cookies.xsrfToken;
|
|
162
193
|
session.loggedInAt = Date.now();
|
|
163
|
-
// Discover linked districts (CUPS SSO) — non-blocking, errors logged not
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
// Discover linked districts (CUPS SSO) — non-blocking, errors logged not
|
|
195
|
+
// thrown. By construction we only reach here for the primary (or fetchproxy
|
|
196
|
+
// primary): linked districts return early above and re-auth via the primary,
|
|
197
|
+
// so no `linkedTo` guard is needed.
|
|
198
|
+
await this.discoverLinkedDistricts(account);
|
|
167
199
|
}
|
|
168
200
|
async discoverLinkedDistricts(account) {
|
|
169
201
|
try {
|
|
@@ -379,11 +411,19 @@ export class UnknownDistrictError extends Error {
|
|
|
379
411
|
export class AuthFailedError extends Error {
|
|
380
412
|
district;
|
|
381
413
|
reason;
|
|
382
|
-
|
|
414
|
+
/**
|
|
415
|
+
* @param opts.credentialHint When `false`, the message omits the
|
|
416
|
+
* "Check IC_USERNAME and IC_PASSWORD" suffix — used for failures where the
|
|
417
|
+
* credentials are known-good (e.g. a linked-district CUPS/SSO re-discovery
|
|
418
|
+
* failure) and pointing the user at their creds would be misleading.
|
|
419
|
+
*/
|
|
420
|
+
constructor(district, reason, opts) {
|
|
383
421
|
const detail = reason ? ` (${reason})` : '';
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
422
|
+
const remedy = opts?.credentialHint === false
|
|
423
|
+
? 'Sign in again at the IC portal in your browser, then restart the MCP.'
|
|
424
|
+
: 'Check IC_USERNAME and IC_PASSWORD; ' +
|
|
425
|
+
'if those are correct, the account may be locked or the portal may be down.';
|
|
426
|
+
super(`Login failed for district '${district}'${detail}. ${remedy}`);
|
|
387
427
|
this.district = district;
|
|
388
428
|
this.reason = reason;
|
|
389
429
|
this.name = 'AuthFailedError';
|
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.
|
|
60
|
+
version: '2.3.4', // 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.
|
|
3
|
+
"version": "2.3.4",
|
|
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>",
|
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.
|
|
9
|
+
"version": "2.3.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.3.
|
|
14
|
+
"version": "2.3.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|