infinitecampus-mcp 2.4.2 → 2.4.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/README.md +1 -1
- package/dist/auth.js +106 -56
- package/dist/bundle.js +225 -117
- package/dist/client.js +38 -39
- package/dist/index.js +4 -4
- package/package.json +2 -2
- 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.4.
|
|
10
|
+
"version": "2.4.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.4.
|
|
18
|
+
"version": "2.4.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.4.
|
|
4
|
+
"version": "2.4.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/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Tools that the harness will gate as write/IO operations: `ic_download_document`.
|
|
|
42
42
|
IC_PASSWORD=...
|
|
43
43
|
IC_NAME=Springfield # optional, defaults to IC_DISTRICT
|
|
44
44
|
```
|
|
45
|
-
2. **fetchproxy fallback (no password needed).** Set only `IC_BASE_URL` + `IC_DISTRICT` (still required so the MCP knows which host to talk to and which district to dispatch on), then install the [fetchproxy 0.3.0 extension](https://github.com/chrischall/fetchproxy) (Chrome Web Store / Safari `.dmg`) and sign into your IC portal once. The MCP reads the `JSESSIONID` (HttpOnly) + `XSRF-TOKEN` cookies
|
|
45
|
+
2. **fetchproxy fallback (no password needed).** Set only `IC_BASE_URL` + `IC_DISTRICT` (still required so the MCP knows which host to talk to and which district to dispatch on), then install the [fetchproxy 0.3.0 extension](https://github.com/chrischall/fetchproxy) (Chrome Web Store / Safari `.dmg`) and sign into your IC portal once. The MCP reads the `JSESSIONID` (HttpOnly) + `XSRF-TOKEN` cookies from that tab and goes direct-to-API from Node thereafter — the extension is **not** in the request hot path. The read is repeated whenever the session lapses (JSESSIONID is a servlet session with a short idle timeout), so you stay signed in without restarting the MCP.
|
|
46
46
|
|
|
47
47
|
Set `IC_DISABLE_FETCHPROXY=1` to opt out of the fallback (turns missing credentials into a hard error — useful in headless CI).
|
|
48
48
|
|
package/dist/auth.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// - `@fetchproxy/bootstrap` is mocked at the module boundary in tests.
|
|
47
47
|
// - `loadAccount()` (the existing env-var resolver) is reused as-is so the
|
|
48
48
|
// legacy paths keep working unchanged.
|
|
49
|
-
import {
|
|
49
|
+
import { createSessionLifter } from '@fetchproxy/bootstrap';
|
|
50
50
|
import { classifyBridgeError } from '@chrischall/mcp-utils/fetchproxy';
|
|
51
51
|
import { parseBoolEnv } from '@chrischall/mcp-utils';
|
|
52
52
|
import { loadAccount } from './config.js';
|
|
@@ -71,65 +71,115 @@ export async function resolveAuth() {
|
|
|
71
71
|
return { account, source: 'env' };
|
|
72
72
|
}
|
|
73
73
|
// ── Path 2: fetchproxy fallback.
|
|
74
|
+
//
|
|
75
|
+
// NOTE: the browser is NOT read here. `resolveAuth()` runs once at process
|
|
76
|
+
// start; the lift is handed to the client as `refresh` and runs lazily on
|
|
77
|
+
// the first request and again on every expiry. Two bugs this avoids:
|
|
78
|
+
//
|
|
79
|
+
// * The lapsed-session dead end. JSESSIONID is a servlet session with a
|
|
80
|
+
// short idle timeout. A cookie captured at boot left the server
|
|
81
|
+
// unauthenticated the moment it lapsed, with no way back — the account
|
|
82
|
+
// has empty creds, so verify.jsp is not an option.
|
|
83
|
+
// * The sticky startup failure. A lift that failed at boot (user not yet
|
|
84
|
+
// signed in) was cached for the life of the process, so signing in
|
|
85
|
+
// afterwards changed nothing.
|
|
74
86
|
if (!fetchproxyDisabled()) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// IC tenants live on per-district hosts (campus.<district>.org,
|
|
81
|
-
// <district>.infinitecampus.org, etc.). Declare just this host
|
|
82
|
-
// — each district is its own root, no wildcard needed.
|
|
83
|
-
domains: [host],
|
|
84
|
-
declare: {
|
|
85
|
-
// JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
|
|
86
|
-
// security gate is the declared key list). XSRF-TOKEN is
|
|
87
|
-
// non-HttpOnly because the page JS reads it back and echoes
|
|
88
|
-
// it on every state-changing call as the X-XSRF-TOKEN header.
|
|
89
|
-
cookies: ['JSESSIONID', 'XSRF-TOKEN'],
|
|
90
|
-
localStorage: [],
|
|
91
|
-
sessionStorage: [],
|
|
92
|
-
captureHeaders: [],
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
const jsessionid = session.cookies['JSESSIONID'];
|
|
96
|
-
const xsrf = session.cookies['XSRF-TOKEN'];
|
|
97
|
-
if (!jsessionid) {
|
|
98
|
-
throw new Error(`JSESSIONID cookie not found on ${host}. ` +
|
|
99
|
-
'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
|
|
100
|
-
}
|
|
101
|
-
if (!xsrf) {
|
|
102
|
-
throw new Error(`XSRF-TOKEN cookie not found on ${host}. ` +
|
|
103
|
-
'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
account,
|
|
107
|
-
preloaded: {
|
|
108
|
-
cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
|
|
109
|
-
xsrfToken: xsrf,
|
|
110
|
-
},
|
|
111
|
-
source: 'fetchproxy',
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
catch (e) {
|
|
115
|
-
// 0.8.0+ typed-error discrimination. The fetchproxy server already
|
|
116
|
-
// retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
|
|
117
|
-
// a thrown FetchproxyBridgeDownError means the retry also failed —
|
|
118
|
-
// the extension's service worker is genuinely down and the user
|
|
119
|
-
// needs to wake it. The `.hint` is the actionable copy
|
|
120
|
-
// ("click the extension toolbar icon...") that we'd otherwise have
|
|
121
|
-
// to hand-write here. Surface it verbatim so users in path 2 get
|
|
122
|
-
// the same self-service guidance as path 3.
|
|
123
|
-
if (classifyBridgeError(e) === 'bridge_down') {
|
|
124
|
-
const downErr = e;
|
|
125
|
-
throw new Error(`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
|
|
126
|
-
}
|
|
127
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
128
|
-
throw new Error('IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy fallback failed: ' + msg);
|
|
129
|
-
}
|
|
87
|
+
return {
|
|
88
|
+
account,
|
|
89
|
+
refresh: () => liftBrowserSession(account),
|
|
90
|
+
source: 'fetchproxy',
|
|
91
|
+
};
|
|
130
92
|
}
|
|
131
93
|
// ── Path 3: nothing configured and fetchproxy explicitly disabled.
|
|
132
94
|
throw new Error('IC auth: set IC_USERNAME + IC_PASSWORD, ' +
|
|
133
95
|
'or install the fetchproxy extension and sign into your IC portal ' +
|
|
134
96
|
'(unset IC_DISABLE_FETCHPROXY if it is set).');
|
|
135
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* One lifter per host, built lazily and reused.
|
|
100
|
+
*
|
|
101
|
+
* Unlike most MCPs the declared scope is not static — IC tenants live on
|
|
102
|
+
* per-district hosts — so the lifter cannot be a module-level constant.
|
|
103
|
+
* Caching by host still matters: `createSessionLifter` single-flights
|
|
104
|
+
* concurrent calls, and that only helps if renewals share ONE lifter instead
|
|
105
|
+
* of constructing a fresh one each time.
|
|
106
|
+
*/
|
|
107
|
+
const liftersByHost = new Map();
|
|
108
|
+
function lifterFor(host) {
|
|
109
|
+
let lift = liftersByHost.get(host);
|
|
110
|
+
if (!lift) {
|
|
111
|
+
lift = createSessionLifter({
|
|
112
|
+
serverName: pkg.name,
|
|
113
|
+
version: pkg.version,
|
|
114
|
+
// IC tenants live on per-district hosts (campus.<district>.org,
|
|
115
|
+
// <district>.infinitecampus.org, etc.). Declare just this host
|
|
116
|
+
// — each district is its own root, no wildcard needed.
|
|
117
|
+
domains: [host],
|
|
118
|
+
// IC is a Tomcat app deployed at /campus, and Tomcat scopes JSESSIONID
|
|
119
|
+
// to the servlet context by default:
|
|
120
|
+
//
|
|
121
|
+
// JSESSIONID=...; Path=/campus; Secure; HttpOnly; SameSite=Lax
|
|
122
|
+
//
|
|
123
|
+
// chrome.cookies.get matches that Path against the URL it is given, so a
|
|
124
|
+
// read aimed at the origin root silently returns nothing — which is
|
|
125
|
+
// indistinguishable from "the user is signed out", and is why this path
|
|
126
|
+
// never worked on any tenant. Verified live: without this the lift found
|
|
127
|
+
// no cookies while a fetch through the same tab returned authenticated
|
|
128
|
+
// data; with it, JSESSIONID + XSRF-TOKEN both come back.
|
|
129
|
+
storagePath: '/campus',
|
|
130
|
+
declare: {
|
|
131
|
+
// JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
|
|
132
|
+
// security gate is the declared key list). XSRF-TOKEN is
|
|
133
|
+
// non-HttpOnly because the page JS reads it back and echoes
|
|
134
|
+
// it on every state-changing call as the X-XSRF-TOKEN header.
|
|
135
|
+
cookies: ['JSESSIONID', 'XSRF-TOKEN'],
|
|
136
|
+
localStorage: [],
|
|
137
|
+
sessionStorage: [],
|
|
138
|
+
captureHeaders: [],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
liftersByHost.set(host, lift);
|
|
142
|
+
}
|
|
143
|
+
return lift;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Lift a fresh IC session out of the user's signed-in portal tab.
|
|
147
|
+
*
|
|
148
|
+
* Runs on every login/renewal, not once at startup — fetchproxy is not in the
|
|
149
|
+
* request hot path, only the renewal path.
|
|
150
|
+
*/
|
|
151
|
+
async function liftBrowserSession(account) {
|
|
152
|
+
try {
|
|
153
|
+
const host = new URL(account.baseUrl).hostname;
|
|
154
|
+
const session = await lifterFor(host)();
|
|
155
|
+
const jsessionid = session.cookies['JSESSIONID'];
|
|
156
|
+
const xsrf = session.cookies['XSRF-TOKEN'];
|
|
157
|
+
if (!jsessionid) {
|
|
158
|
+
throw new Error(`JSESSIONID cookie not found on ${host}. ` +
|
|
159
|
+
'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
|
|
160
|
+
}
|
|
161
|
+
if (!xsrf) {
|
|
162
|
+
throw new Error(`XSRF-TOKEN cookie not found on ${host}. ` +
|
|
163
|
+
'Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.');
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
|
|
167
|
+
xsrfToken: xsrf,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
// 0.8.0+ typed-error discrimination. The fetchproxy server already
|
|
172
|
+
// retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
|
|
173
|
+
// a thrown FetchproxyBridgeDownError means the retry also failed —
|
|
174
|
+
// the extension's service worker is genuinely down and the user
|
|
175
|
+
// needs to wake it. The `.hint` is the actionable copy
|
|
176
|
+
// ("click the extension toolbar icon...") that we'd otherwise have
|
|
177
|
+
// to hand-write here.
|
|
178
|
+
if (classifyBridgeError(e) === 'bridge_down') {
|
|
179
|
+
const downErr = e;
|
|
180
|
+
throw new Error(`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
|
|
181
|
+
}
|
|
182
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
183
|
+
throw new Error('IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy lift failed: ' + msg);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/bundle.js
CHANGED
|
@@ -26729,17 +26729,33 @@ function normalizeObjectSchema(schema) {
|
|
|
26729
26729
|
}
|
|
26730
26730
|
return void 0;
|
|
26731
26731
|
}
|
|
26732
|
+
function getDotPath(path) {
|
|
26733
|
+
if (path.length === 0) {
|
|
26734
|
+
return "object root";
|
|
26735
|
+
}
|
|
26736
|
+
return path.reduce((acc, seg, index) => {
|
|
26737
|
+
if (index === 0) {
|
|
26738
|
+
return String(seg);
|
|
26739
|
+
}
|
|
26740
|
+
if (typeof seg === "number") {
|
|
26741
|
+
return `${acc}[${seg}]`;
|
|
26742
|
+
}
|
|
26743
|
+
return `${acc}.${seg}`;
|
|
26744
|
+
}, "");
|
|
26745
|
+
}
|
|
26732
26746
|
function getParseErrorMessage(error51) {
|
|
26733
26747
|
if (error51 && typeof error51 === "object") {
|
|
26748
|
+
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
26749
|
+
return error51.issues.map((i) => {
|
|
26750
|
+
if (!i.path?.length) {
|
|
26751
|
+
return i.message;
|
|
26752
|
+
}
|
|
26753
|
+
return `${i.message} at ${getDotPath(i.path)}`;
|
|
26754
|
+
}).join("\n");
|
|
26755
|
+
}
|
|
26734
26756
|
if ("message" in error51 && typeof error51.message === "string") {
|
|
26735
26757
|
return error51.message;
|
|
26736
26758
|
}
|
|
26737
|
-
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
26738
|
-
const firstIssue = error51.issues[0];
|
|
26739
|
-
if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
|
|
26740
|
-
return String(firstIssue.message);
|
|
26741
|
-
}
|
|
26742
|
-
}
|
|
26743
26759
|
try {
|
|
26744
26760
|
return JSON.stringify(error51);
|
|
26745
26761
|
} catch {
|
|
@@ -33354,16 +33370,7 @@ var Server = class extends Protocol {
|
|
|
33354
33370
|
if (!methodSchema) {
|
|
33355
33371
|
throw new Error("Schema is missing a method literal");
|
|
33356
33372
|
}
|
|
33357
|
-
|
|
33358
|
-
if (isZ4Schema(methodSchema)) {
|
|
33359
|
-
const v4Schema = methodSchema;
|
|
33360
|
-
const v4Def = v4Schema._zod?.def;
|
|
33361
|
-
methodValue = v4Def?.value ?? v4Schema.value;
|
|
33362
|
-
} else {
|
|
33363
|
-
const v3Schema = methodSchema;
|
|
33364
|
-
const legacyDef = v3Schema._def;
|
|
33365
|
-
methodValue = legacyDef?.value ?? v3Schema.value;
|
|
33366
|
-
}
|
|
33373
|
+
const methodValue = getLiteralValue(methodSchema);
|
|
33367
33374
|
if (typeof methodValue !== "string") {
|
|
33368
33375
|
throw new Error("Schema method literal must be a string");
|
|
33369
33376
|
}
|
|
@@ -34551,8 +34558,17 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
34551
34558
|
import process3 from "node:process";
|
|
34552
34559
|
|
|
34553
34560
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
|
|
34561
|
+
var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
34554
34562
|
var ReadBuffer = class {
|
|
34563
|
+
constructor(options) {
|
|
34564
|
+
this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
34565
|
+
}
|
|
34555
34566
|
append(chunk2) {
|
|
34567
|
+
const newSize = (this._buffer?.length ?? 0) + chunk2.length;
|
|
34568
|
+
if (newSize > this._maxBufferSize) {
|
|
34569
|
+
this.clear();
|
|
34570
|
+
throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
|
|
34571
|
+
}
|
|
34556
34572
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk2]) : chunk2;
|
|
34557
34573
|
}
|
|
34558
34574
|
readMessage() {
|
|
@@ -34580,18 +34596,24 @@ function serializeMessage(message) {
|
|
|
34580
34596
|
|
|
34581
34597
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
|
|
34582
34598
|
var StdioServerTransport = class {
|
|
34583
|
-
constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
|
|
34599
|
+
constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
|
|
34584
34600
|
this._stdin = _stdin;
|
|
34585
34601
|
this._stdout = _stdout;
|
|
34586
|
-
this._readBuffer = new ReadBuffer();
|
|
34587
34602
|
this._started = false;
|
|
34588
34603
|
this._ondata = (chunk2) => {
|
|
34589
|
-
|
|
34590
|
-
|
|
34604
|
+
try {
|
|
34605
|
+
this._readBuffer.append(chunk2);
|
|
34606
|
+
this.processReadBuffer();
|
|
34607
|
+
} catch (error51) {
|
|
34608
|
+
this.onerror?.(error51);
|
|
34609
|
+
this.close().catch(() => {
|
|
34610
|
+
});
|
|
34611
|
+
}
|
|
34591
34612
|
};
|
|
34592
34613
|
this._onerror = (error51) => {
|
|
34593
34614
|
this.onerror?.(error51);
|
|
34594
34615
|
};
|
|
34616
|
+
this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
|
|
34595
34617
|
}
|
|
34596
34618
|
/**
|
|
34597
34619
|
* Starts listening for messages on stdin.
|
|
@@ -34955,6 +34977,15 @@ function assertHttpUrl(x, label) {
|
|
|
34955
34977
|
throw new ProtocolError(`${label}: must be http(s), got ${u.protocol}`);
|
|
34956
34978
|
}
|
|
34957
34979
|
}
|
|
34980
|
+
function assertCookiePath(x, label) {
|
|
34981
|
+
assertString(x, label);
|
|
34982
|
+
if (!x.startsWith("/") || x.startsWith("//")) {
|
|
34983
|
+
throw new ProtocolError(`${label}: must be an absolute path like "/campus"`);
|
|
34984
|
+
}
|
|
34985
|
+
if (x.includes("?") || x.includes("#") || x.includes("\\")) {
|
|
34986
|
+
throw new ProtocolError(`${label}: must not contain a query, fragment, or backslash`);
|
|
34987
|
+
}
|
|
34988
|
+
}
|
|
34958
34989
|
function assertHttpsOriginOnly(x, label) {
|
|
34959
34990
|
assertString(x, label);
|
|
34960
34991
|
let u;
|
|
@@ -35399,8 +35430,10 @@ function validateInnerRequest(raw) {
|
|
|
35399
35430
|
}
|
|
35400
35431
|
assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
|
|
35401
35432
|
assertNonEmptyKeyArray(raw.init.keys, "inner.init.keys");
|
|
35433
|
+
if (raw.init.path !== void 0)
|
|
35434
|
+
assertCookiePath(raw.init.path, "inner.init.path");
|
|
35402
35435
|
for (const k of Object.keys(raw.init)) {
|
|
35403
|
-
if (k !== "origin" && k !== "keys") {
|
|
35436
|
+
if (k !== "origin" && k !== "keys" && k !== "path") {
|
|
35404
35437
|
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_cookies`);
|
|
35405
35438
|
}
|
|
35406
35439
|
}
|
|
@@ -36631,6 +36664,22 @@ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
|
|
|
36631
36664
|
this.hint = hint;
|
|
36632
36665
|
}
|
|
36633
36666
|
};
|
|
36667
|
+
var FetchproxyScopeError = class extends FetchproxyProtocolError {
|
|
36668
|
+
/** The extension's raw rejection, unmodified. */
|
|
36669
|
+
originalError;
|
|
36670
|
+
hint;
|
|
36671
|
+
constructor(originalError) {
|
|
36672
|
+
const hint = "the declared scope changed since you paired, so the extension is refusing the request. Revoke this MCP in the Transporter extension popup, then re-run \u2014 you will be asked to approve the new scope. This is not a version problem and does not need an update.";
|
|
36673
|
+
super(`${originalError} \u2014 ${hint}`);
|
|
36674
|
+
this.name = "FetchproxyScopeError";
|
|
36675
|
+
this.originalError = originalError;
|
|
36676
|
+
this.hint = hint;
|
|
36677
|
+
}
|
|
36678
|
+
};
|
|
36679
|
+
var SCOPE_REJECTION = /not in declared/;
|
|
36680
|
+
function protocolErrorFrom(error51) {
|
|
36681
|
+
return SCOPE_REJECTION.test(error51) ? new FetchproxyScopeError(error51) : new FetchproxyProtocolError(error51);
|
|
36682
|
+
}
|
|
36634
36683
|
var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
|
|
36635
36684
|
url;
|
|
36636
36685
|
timeoutMs;
|
|
@@ -36660,6 +36709,17 @@ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
|
|
|
36660
36709
|
}
|
|
36661
36710
|
};
|
|
36662
36711
|
var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
|
|
36712
|
+
function normalizeCookiePath(path) {
|
|
36713
|
+
if (path === void 0 || path === "")
|
|
36714
|
+
return void 0;
|
|
36715
|
+
const trimmed = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
36716
|
+
try {
|
|
36717
|
+
assertCookiePath(trimmed, "path");
|
|
36718
|
+
} catch (e) {
|
|
36719
|
+
throw new Error(`FetchproxyServer: ${e instanceof Error ? e.message : String(e)} (got ${JSON.stringify(path)})`);
|
|
36720
|
+
}
|
|
36721
|
+
return trimmed;
|
|
36722
|
+
}
|
|
36663
36723
|
function assertSubdomainLabel(label) {
|
|
36664
36724
|
if (!SUBDOMAIN_LABEL_RE.test(label)) {
|
|
36665
36725
|
throw new Error(`FetchproxyServer: subdomain must be a DNS label like "www" or "api" (or dot-separated like "auth.api"), got ${JSON.stringify(label)}`);
|
|
@@ -37301,7 +37361,7 @@ var FetchproxyServer = class {
|
|
|
37301
37361
|
port: this.opts.port
|
|
37302
37362
|
});
|
|
37303
37363
|
}
|
|
37304
|
-
return
|
|
37364
|
+
return protocolErrorFrom(result.error);
|
|
37305
37365
|
}
|
|
37306
37366
|
/**
|
|
37307
37367
|
* Convenience wrapper around `fetch()`. Builds the URL from a path
|
|
@@ -37552,9 +37612,14 @@ var FetchproxyServer = class {
|
|
|
37552
37612
|
let inner;
|
|
37553
37613
|
if (opts.keys !== void 0) {
|
|
37554
37614
|
this.assertScopeSubset(opts.keys, this.opts.cookieKeys, "cookieKeys");
|
|
37615
|
+
const cookiePath = normalizeCookiePath(opts.path);
|
|
37555
37616
|
const initV3 = {
|
|
37617
|
+
// Origin stays BARE. The path travels as its own validated field —
|
|
37618
|
+
// `assertHttpsOriginOnly` deliberately refuses a path here so one
|
|
37619
|
+
// cannot be used to re-point the read past the domain gate.
|
|
37556
37620
|
origin: `https://${host}`,
|
|
37557
|
-
keys: [...opts.keys]
|
|
37621
|
+
keys: [...opts.keys],
|
|
37622
|
+
...cookiePath !== void 0 ? { path: cookiePath } : {}
|
|
37558
37623
|
};
|
|
37559
37624
|
inner = { type: "request", id, op: "read_cookies", init: initV3 };
|
|
37560
37625
|
} else {
|
|
@@ -37567,7 +37632,7 @@ var FetchproxyServer = class {
|
|
|
37567
37632
|
await this.sendInnerFrame(inner);
|
|
37568
37633
|
const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
|
|
37569
37634
|
if (!result.ok) {
|
|
37570
|
-
throw
|
|
37635
|
+
throw protocolErrorFrom(result.error);
|
|
37571
37636
|
}
|
|
37572
37637
|
return result.cookies;
|
|
37573
37638
|
}
|
|
@@ -38117,7 +38182,7 @@ var FetchproxyServer = class {
|
|
|
38117
38182
|
storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
|
|
38118
38183
|
}
|
|
38119
38184
|
} else {
|
|
38120
|
-
storageCb.reject(
|
|
38185
|
+
storageCb.reject(protocolErrorFrom(inner.error));
|
|
38121
38186
|
}
|
|
38122
38187
|
return;
|
|
38123
38188
|
}
|
|
@@ -38131,7 +38196,7 @@ var FetchproxyServer = class {
|
|
|
38131
38196
|
captureCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture awaiter`));
|
|
38132
38197
|
}
|
|
38133
38198
|
} else {
|
|
38134
|
-
captureCb.reject(
|
|
38199
|
+
captureCb.reject(protocolErrorFrom(inner.error));
|
|
38135
38200
|
}
|
|
38136
38201
|
return;
|
|
38137
38202
|
}
|
|
@@ -38145,7 +38210,7 @@ var FetchproxyServer = class {
|
|
|
38145
38210
|
redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
|
|
38146
38211
|
}
|
|
38147
38212
|
} else {
|
|
38148
|
-
redirectCb.reject(
|
|
38213
|
+
redirectCb.reject(protocolErrorFrom(inner.error));
|
|
38149
38214
|
}
|
|
38150
38215
|
return;
|
|
38151
38216
|
}
|
|
@@ -38159,7 +38224,7 @@ var FetchproxyServer = class {
|
|
|
38159
38224
|
idbCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on read_indexed_db awaiter`));
|
|
38160
38225
|
}
|
|
38161
38226
|
} else {
|
|
38162
|
-
idbCb.reject(
|
|
38227
|
+
idbCb.reject(protocolErrorFrom(inner.error));
|
|
38163
38228
|
}
|
|
38164
38229
|
return;
|
|
38165
38230
|
}
|
|
@@ -38173,7 +38238,7 @@ var FetchproxyServer = class {
|
|
|
38173
38238
|
downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
|
|
38174
38239
|
}
|
|
38175
38240
|
} else {
|
|
38176
|
-
downloadCb.reject(
|
|
38241
|
+
downloadCb.reject(protocolErrorFrom(inner.error));
|
|
38177
38242
|
}
|
|
38178
38243
|
return;
|
|
38179
38244
|
}
|
|
@@ -38187,7 +38252,7 @@ var FetchproxyServer = class {
|
|
|
38187
38252
|
graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
|
|
38188
38253
|
}
|
|
38189
38254
|
} else {
|
|
38190
|
-
graphqlCb.reject(
|
|
38255
|
+
graphqlCb.reject(protocolErrorFrom(inner.error));
|
|
38191
38256
|
}
|
|
38192
38257
|
return;
|
|
38193
38258
|
}
|
|
@@ -38298,7 +38363,20 @@ var FetchproxyServer = class {
|
|
|
38298
38363
|
|
|
38299
38364
|
// node_modules/@fetchproxy/bootstrap/dist/index.js
|
|
38300
38365
|
var defaultFactory = (opts) => new FetchproxyServer(opts);
|
|
38301
|
-
|
|
38366
|
+
function createSessionLifter(opts) {
|
|
38367
|
+
let inFlight = null;
|
|
38368
|
+
return () => {
|
|
38369
|
+
if (inFlight)
|
|
38370
|
+
return inFlight;
|
|
38371
|
+
const p = runOneLift(opts).finally(() => {
|
|
38372
|
+
if (inFlight === p)
|
|
38373
|
+
inFlight = null;
|
|
38374
|
+
});
|
|
38375
|
+
inFlight = p;
|
|
38376
|
+
return p;
|
|
38377
|
+
};
|
|
38378
|
+
}
|
|
38379
|
+
async function runOneLift(opts) {
|
|
38302
38380
|
const envVar = opts.serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "") + "_DISABLE_FETCHPROXY";
|
|
38303
38381
|
const envVal = process.env[envVar];
|
|
38304
38382
|
if (envVal !== void 0 && envVal !== "" && envVal !== "0" && envVal !== "false") {
|
|
@@ -38367,7 +38445,9 @@ async function bootstrap(opts) {
|
|
|
38367
38445
|
if (opts.declare.cookies.length > 0) {
|
|
38368
38446
|
const joined = await server.readCookies({
|
|
38369
38447
|
keys: opts.declare.cookies,
|
|
38370
|
-
...storageDomainOpts
|
|
38448
|
+
...storageDomainOpts,
|
|
38449
|
+
// Cookie-only: the other buckets are origin-scoped and ignore path.
|
|
38450
|
+
...opts.storagePath !== void 0 ? { path: opts.storagePath } : {}
|
|
38371
38451
|
});
|
|
38372
38452
|
for (const piece of joined.split("; ")) {
|
|
38373
38453
|
if (!piece)
|
|
@@ -38425,12 +38505,18 @@ async function bootstrap(opts) {
|
|
|
38425
38505
|
});
|
|
38426
38506
|
indexedDbBucket[`${d.database}/${d.store}`] = values;
|
|
38427
38507
|
}
|
|
38508
|
+
const absent = (declared, got) => declared.filter((k) => !(k in got));
|
|
38428
38509
|
return {
|
|
38429
38510
|
cookies,
|
|
38430
38511
|
localStorage,
|
|
38431
38512
|
sessionStorage,
|
|
38432
38513
|
capturedHeaders,
|
|
38433
|
-
indexedDb: indexedDbBucket
|
|
38514
|
+
indexedDb: indexedDbBucket,
|
|
38515
|
+
missing: {
|
|
38516
|
+
cookies: absent(opts.declare.cookies, cookies),
|
|
38517
|
+
localStorage: absent(opts.declare.localStorage, localStorage),
|
|
38518
|
+
sessionStorage: absent(opts.declare.sessionStorage, sessionStorage)
|
|
38519
|
+
}
|
|
38434
38520
|
};
|
|
38435
38521
|
} finally {
|
|
38436
38522
|
try {
|
|
@@ -38491,7 +38577,7 @@ function loadAccount(env = process.env) {
|
|
|
38491
38577
|
// package.json
|
|
38492
38578
|
var package_default = {
|
|
38493
38579
|
name: "infinitecampus-mcp",
|
|
38494
|
-
version: "2.4.
|
|
38580
|
+
version: "2.4.4",
|
|
38495
38581
|
mcpName: "io.github.chrischall/infinitecampus-mcp",
|
|
38496
38582
|
description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
|
|
38497
38583
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -38534,7 +38620,7 @@ var package_default = {
|
|
|
38534
38620
|
},
|
|
38535
38621
|
dependencies: {
|
|
38536
38622
|
"@chrischall/mcp-utils": "^0.14.0",
|
|
38537
|
-
"@fetchproxy/bootstrap": "^1.
|
|
38623
|
+
"@fetchproxy/bootstrap": "^1.11.0",
|
|
38538
38624
|
"@fetchproxy/server": "^1.7.0",
|
|
38539
38625
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38540
38626
|
dotenv: "^17.4.2",
|
|
@@ -38560,62 +38646,86 @@ async function resolveAuth() {
|
|
|
38560
38646
|
return { account: account2, source: "env" };
|
|
38561
38647
|
}
|
|
38562
38648
|
if (!fetchproxyDisabled()) {
|
|
38563
|
-
|
|
38564
|
-
|
|
38565
|
-
|
|
38566
|
-
|
|
38567
|
-
|
|
38568
|
-
|
|
38569
|
-
|
|
38570
|
-
|
|
38571
|
-
|
|
38572
|
-
|
|
38573
|
-
|
|
38574
|
-
|
|
38575
|
-
|
|
38576
|
-
|
|
38577
|
-
|
|
38578
|
-
|
|
38579
|
-
|
|
38580
|
-
|
|
38581
|
-
|
|
38582
|
-
|
|
38583
|
-
|
|
38584
|
-
|
|
38585
|
-
|
|
38586
|
-
|
|
38587
|
-
|
|
38588
|
-
|
|
38589
|
-
|
|
38590
|
-
|
|
38591
|
-
|
|
38592
|
-
|
|
38593
|
-
|
|
38594
|
-
|
|
38595
|
-
|
|
38596
|
-
|
|
38597
|
-
|
|
38598
|
-
|
|
38599
|
-
|
|
38600
|
-
|
|
38601
|
-
|
|
38602
|
-
|
|
38603
|
-
|
|
38604
|
-
|
|
38605
|
-
const downErr = e;
|
|
38606
|
-
throw new Error(
|
|
38607
|
-
`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
|
|
38608
|
-
);
|
|
38649
|
+
return {
|
|
38650
|
+
account: account2,
|
|
38651
|
+
refresh: () => liftBrowserSession(account2),
|
|
38652
|
+
source: "fetchproxy"
|
|
38653
|
+
};
|
|
38654
|
+
}
|
|
38655
|
+
throw new Error(
|
|
38656
|
+
"IC auth: set IC_USERNAME + IC_PASSWORD, or install the fetchproxy extension and sign into your IC portal (unset IC_DISABLE_FETCHPROXY if it is set)."
|
|
38657
|
+
);
|
|
38658
|
+
}
|
|
38659
|
+
var liftersByHost = /* @__PURE__ */ new Map();
|
|
38660
|
+
function lifterFor(host) {
|
|
38661
|
+
let lift = liftersByHost.get(host);
|
|
38662
|
+
if (!lift) {
|
|
38663
|
+
lift = createSessionLifter({
|
|
38664
|
+
serverName: package_default.name,
|
|
38665
|
+
version: package_default.version,
|
|
38666
|
+
// IC tenants live on per-district hosts (campus.<district>.org,
|
|
38667
|
+
// <district>.infinitecampus.org, etc.). Declare just this host
|
|
38668
|
+
// — each district is its own root, no wildcard needed.
|
|
38669
|
+
domains: [host],
|
|
38670
|
+
// IC is a Tomcat app deployed at /campus, and Tomcat scopes JSESSIONID
|
|
38671
|
+
// to the servlet context by default:
|
|
38672
|
+
//
|
|
38673
|
+
// JSESSIONID=...; Path=/campus; Secure; HttpOnly; SameSite=Lax
|
|
38674
|
+
//
|
|
38675
|
+
// chrome.cookies.get matches that Path against the URL it is given, so a
|
|
38676
|
+
// read aimed at the origin root silently returns nothing — which is
|
|
38677
|
+
// indistinguishable from "the user is signed out", and is why this path
|
|
38678
|
+
// never worked on any tenant. Verified live: without this the lift found
|
|
38679
|
+
// no cookies while a fetch through the same tab returned authenticated
|
|
38680
|
+
// data; with it, JSESSIONID + XSRF-TOKEN both come back.
|
|
38681
|
+
storagePath: "/campus",
|
|
38682
|
+
declare: {
|
|
38683
|
+
// JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
|
|
38684
|
+
// security gate is the declared key list). XSRF-TOKEN is
|
|
38685
|
+
// non-HttpOnly because the page JS reads it back and echoes
|
|
38686
|
+
// it on every state-changing call as the X-XSRF-TOKEN header.
|
|
38687
|
+
cookies: ["JSESSIONID", "XSRF-TOKEN"],
|
|
38688
|
+
localStorage: [],
|
|
38689
|
+
sessionStorage: [],
|
|
38690
|
+
captureHeaders: []
|
|
38609
38691
|
}
|
|
38610
|
-
|
|
38692
|
+
});
|
|
38693
|
+
liftersByHost.set(host, lift);
|
|
38694
|
+
}
|
|
38695
|
+
return lift;
|
|
38696
|
+
}
|
|
38697
|
+
async function liftBrowserSession(account2) {
|
|
38698
|
+
try {
|
|
38699
|
+
const host = new URL(account2.baseUrl).hostname;
|
|
38700
|
+
const session = await lifterFor(host)();
|
|
38701
|
+
const jsessionid = session.cookies["JSESSIONID"];
|
|
38702
|
+
const xsrf = session.cookies["XSRF-TOKEN"];
|
|
38703
|
+
if (!jsessionid) {
|
|
38704
|
+
throw new Error(
|
|
38705
|
+
`JSESSIONID cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
|
|
38706
|
+
);
|
|
38707
|
+
}
|
|
38708
|
+
if (!xsrf) {
|
|
38611
38709
|
throw new Error(
|
|
38612
|
-
|
|
38710
|
+
`XSRF-TOKEN cookie not found on ${host}. Sign into your IC portal in your browser (with the fetchproxy extension installed) and retry.`
|
|
38613
38711
|
);
|
|
38614
38712
|
}
|
|
38713
|
+
return {
|
|
38714
|
+
cookieHeader: `JSESSIONID=${jsessionid}; XSRF-TOKEN=${xsrf}`,
|
|
38715
|
+
xsrfToken: xsrf
|
|
38716
|
+
};
|
|
38717
|
+
} catch (e) {
|
|
38718
|
+
if (classifyBridgeError(e) === "bridge_down") {
|
|
38719
|
+
const downErr = e;
|
|
38720
|
+
throw new Error(
|
|
38721
|
+
`IC auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
|
|
38722
|
+
);
|
|
38723
|
+
}
|
|
38724
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
38725
|
+
throw new Error(
|
|
38726
|
+
"IC auth: no IC_USERNAME/IC_PASSWORD set, and fetchproxy lift failed: " + msg
|
|
38727
|
+
);
|
|
38615
38728
|
}
|
|
38616
|
-
throw new Error(
|
|
38617
|
-
"IC auth: set IC_USERNAME + IC_PASSWORD, or install the fetchproxy extension and sign into your IC portal (unset IC_DISABLE_FETCHPROXY if it is set)."
|
|
38618
|
-
);
|
|
38619
38729
|
}
|
|
38620
38730
|
|
|
38621
38731
|
// src/client.ts
|
|
@@ -38790,24 +38900,29 @@ var ICClient = class {
|
|
|
38790
38900
|
* first call to `ensureDiscovery()`.
|
|
38791
38901
|
*/
|
|
38792
38902
|
fetchproxyMode = false;
|
|
38793
|
-
|
|
38794
|
-
|
|
38795
|
-
|
|
38796
|
-
|
|
38797
|
-
|
|
38798
|
-
|
|
38799
|
-
|
|
38800
|
-
*
|
|
38801
|
-
*
|
|
38802
|
-
*
|
|
38803
|
-
*
|
|
38903
|
+
/**
|
|
38904
|
+
* fetchproxy lift. Called by the primary manager's login — on the first
|
|
38905
|
+
* request AND on every renewal, which is what keeps the path alive past
|
|
38906
|
+
* JSESSIONID's idle timeout.
|
|
38907
|
+
*/
|
|
38908
|
+
refreshSession = null;
|
|
38909
|
+
/**
|
|
38910
|
+
* `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
|
|
38911
|
+
* the `verify.jsp` POST as the way this client mints a primary session.
|
|
38912
|
+
* It is called lazily on the first request and again on every expiry, so a
|
|
38913
|
+
* lapsed servlet session recovers by re-reading the browser rather than
|
|
38914
|
+
* dead-ending. CUPS linked-district discovery still runs on first request.
|
|
38915
|
+
*
|
|
38916
|
+
* It must re-read the browser each time rather than return a captured
|
|
38917
|
+
* value: JSESSIONID lapses on a short idle timer and the account has no
|
|
38918
|
+
* credentials to fall back on.
|
|
38804
38919
|
*/
|
|
38805
38920
|
constructor(account2, opts = {}) {
|
|
38806
38921
|
this.accounts.set(account2.name, account2);
|
|
38807
38922
|
this.primaryName = account2.name;
|
|
38808
38923
|
this.managers.set(account2.name, this.makeManager(account2));
|
|
38809
|
-
if (opts.
|
|
38810
|
-
this.
|
|
38924
|
+
if (opts.refreshSession) {
|
|
38925
|
+
this.refreshSession = opts.refreshSession;
|
|
38811
38926
|
this.fetchproxyMode = true;
|
|
38812
38927
|
}
|
|
38813
38928
|
}
|
|
@@ -38862,10 +38977,7 @@ var ICClient = class {
|
|
|
38862
38977
|
}
|
|
38863
38978
|
async ensureDiscovery() {
|
|
38864
38979
|
const session = await this.managers.get(this.primaryName).ensure();
|
|
38865
|
-
|
|
38866
|
-
this.fetchproxyDiscoveryRan = true;
|
|
38867
|
-
await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
|
|
38868
|
-
}
|
|
38980
|
+
void session;
|
|
38869
38981
|
}
|
|
38870
38982
|
listDistricts() {
|
|
38871
38983
|
return [...this.accounts.values()].map((a) => ({
|
|
@@ -38897,11 +39009,7 @@ var ICClient = class {
|
|
|
38897
39009
|
account2 = this.accounts.get(district);
|
|
38898
39010
|
if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
|
|
38899
39011
|
}
|
|
38900
|
-
|
|
38901
|
-
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account2.name === this.primaryName) {
|
|
38902
|
-
this.fetchproxyDiscoveryRan = true;
|
|
38903
|
-
await this.discoverLinkedDistricts(account2, session);
|
|
38904
|
-
}
|
|
39012
|
+
await this.managers.get(account2.name).ensure();
|
|
38905
39013
|
return this.doRequest(account2, path, opts);
|
|
38906
39014
|
}
|
|
38907
39015
|
async login(account2) {
|
|
@@ -38918,15 +39026,15 @@ var ICClient = class {
|
|
|
38918
39026
|
}
|
|
38919
39027
|
return restored;
|
|
38920
39028
|
}
|
|
38921
|
-
if (this.
|
|
38922
|
-
const
|
|
38923
|
-
this.
|
|
38924
|
-
return
|
|
39029
|
+
if (this.refreshSession) {
|
|
39030
|
+
const session2 = await this.refreshSession();
|
|
39031
|
+
await this.discoverLinkedDistricts(account2, session2);
|
|
39032
|
+
return session2;
|
|
38925
39033
|
}
|
|
38926
39034
|
if (!account2.username || !account2.password) {
|
|
38927
39035
|
throw new AuthFailedError(
|
|
38928
39036
|
account2.name,
|
|
38929
|
-
"session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser
|
|
39037
|
+
"session expired and no IC_USERNAME/IC_PASSWORD set \u2014 sign back into your IC portal in the browser, or set IC_USERNAME/IC_PASSWORD",
|
|
38930
39038
|
{ permanent: true }
|
|
38931
39039
|
);
|
|
38932
39040
|
}
|
|
@@ -40094,24 +40202,24 @@ await loadDotenvSafely({
|
|
|
40094
40202
|
});
|
|
40095
40203
|
var AI_NOTICE = "[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.";
|
|
40096
40204
|
var account = null;
|
|
40097
|
-
var
|
|
40205
|
+
var refreshSession;
|
|
40098
40206
|
var source;
|
|
40099
40207
|
var configError = null;
|
|
40100
40208
|
try {
|
|
40101
40209
|
const resolved = await resolveAuth();
|
|
40102
40210
|
account = resolved.account;
|
|
40103
|
-
|
|
40211
|
+
refreshSession = resolved.refresh;
|
|
40104
40212
|
source = resolved.source;
|
|
40105
40213
|
} catch (e) {
|
|
40106
40214
|
configError = e;
|
|
40107
40215
|
}
|
|
40108
40216
|
var COMMON = {
|
|
40109
40217
|
name: "infinitecampus",
|
|
40110
|
-
version: "2.4.
|
|
40218
|
+
version: "2.4.4"
|
|
40111
40219
|
// x-release-please-version
|
|
40112
40220
|
};
|
|
40113
40221
|
if (account) {
|
|
40114
|
-
const client = new ICClient(account, {
|
|
40222
|
+
const client = new ICClient(account, { refreshSession });
|
|
40115
40223
|
const tools = [
|
|
40116
40224
|
registerDistrictTools,
|
|
40117
40225
|
registerStudentTools,
|
package/dist/client.js
CHANGED
|
@@ -25,24 +25,29 @@ export class ICClient {
|
|
|
25
25
|
* first call to `ensureDiscovery()`.
|
|
26
26
|
*/
|
|
27
27
|
fetchproxyMode = false;
|
|
28
|
-
fetchproxyDiscoveryRan = false;
|
|
29
|
-
/** fetchproxy cookies, consumed by the primary manager's first login. */
|
|
30
|
-
preloaded = null;
|
|
31
28
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* the
|
|
29
|
+
* fetchproxy lift. Called by the primary manager's login — on the first
|
|
30
|
+
* request AND on every renewal, which is what keeps the path alive past
|
|
31
|
+
* JSESSIONID's idle timeout.
|
|
32
|
+
*/
|
|
33
|
+
refreshSession = null;
|
|
34
|
+
/**
|
|
35
|
+
* `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
|
|
36
|
+
* the `verify.jsp` POST as the way this client mints a primary session.
|
|
37
|
+
* It is called lazily on the first request and again on every expiry, so a
|
|
38
|
+
* lapsed servlet session recovers by re-reading the browser rather than
|
|
39
|
+
* dead-ending. CUPS linked-district discovery still runs on first request.
|
|
40
|
+
*
|
|
41
|
+
* It must re-read the browser each time rather than return a captured
|
|
42
|
+
* value: JSESSIONID lapses on a short idle timer and the account has no
|
|
43
|
+
* credentials to fall back on.
|
|
39
44
|
*/
|
|
40
45
|
constructor(account, opts = {}) {
|
|
41
46
|
this.accounts.set(account.name, account);
|
|
42
47
|
this.primaryName = account.name;
|
|
43
48
|
this.managers.set(account.name, this.makeManager(account));
|
|
44
|
-
if (opts.
|
|
45
|
-
this.
|
|
49
|
+
if (opts.refreshSession) {
|
|
50
|
+
this.refreshSession = opts.refreshSession;
|
|
46
51
|
this.fetchproxyMode = true;
|
|
47
52
|
}
|
|
48
53
|
}
|
|
@@ -98,16 +103,11 @@ export class ICClient {
|
|
|
98
103
|
async ensureDiscovery() {
|
|
99
104
|
// Ensure primary account is logged in, which triggers CUPS linked-district discovery
|
|
100
105
|
const session = await this.managers.get(this.primaryName).ensure();
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
// itself.
|
|
107
|
-
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan) {
|
|
108
|
-
this.fetchproxyDiscoveryRan = true;
|
|
109
|
-
await this.discoverLinkedDistricts(this.accounts.get(this.primaryName), session);
|
|
110
|
-
}
|
|
106
|
+
// Nothing further to do: `ensure()` mints the primary session when one is
|
|
107
|
+
// needed, and every primary mint runs discovery — in fetchproxy mode via
|
|
108
|
+
// login()'s lift branch, in env mode via mintSessionCookie(). A cached
|
|
109
|
+
// primary session means discovery already ran for it.
|
|
110
|
+
void session;
|
|
111
111
|
}
|
|
112
112
|
listDistricts() {
|
|
113
113
|
return [...this.accounts.values()].map((a) => ({
|
|
@@ -143,15 +143,7 @@ export class ICClient {
|
|
|
143
143
|
if (!account)
|
|
144
144
|
throw new UnknownDistrictError(district, [...this.accounts.keys()]);
|
|
145
145
|
}
|
|
146
|
-
|
|
147
|
-
// In fetchproxy mode, the preloaded-cookie login never runs verify.jsp
|
|
148
|
-
// and therefore never calls discoverLinkedDistricts. Run discovery once
|
|
149
|
-
// on the first primary call so linked districts are discoverable. (The
|
|
150
|
-
// primary is never in linkedTo by construction.)
|
|
151
|
-
if (this.fetchproxyMode && !this.fetchproxyDiscoveryRan && account.name === this.primaryName) {
|
|
152
|
-
this.fetchproxyDiscoveryRan = true;
|
|
153
|
-
await this.discoverLinkedDistricts(account, session);
|
|
154
|
-
}
|
|
146
|
+
await this.managers.get(account.name).ensure();
|
|
155
147
|
return this.doRequest(account, path, opts);
|
|
156
148
|
}
|
|
157
149
|
async login(account) {
|
|
@@ -179,13 +171,20 @@ export class ICClient {
|
|
|
179
171
|
}
|
|
180
172
|
return restored;
|
|
181
173
|
}
|
|
182
|
-
// fetchproxy mode: the
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
174
|
+
// fetchproxy mode: mint the primary session by re-reading the browser
|
|
175
|
+
// instead of POSTing to verify.jsp. This runs on EVERY login, not just
|
|
176
|
+
// the first — a lapsed JSESSIONID recovers by lifting the live session
|
|
177
|
+
// the browser still holds, which is the whole point.
|
|
178
|
+
if (this.refreshSession) {
|
|
179
|
+
const session = await this.refreshSession();
|
|
180
|
+
// Mirror mintSessionCookie(): a primary re-mint re-runs CUPS discovery
|
|
181
|
+
// so linked districts get re-seeded. Without this a linked district
|
|
182
|
+
// could never recover — its login() defers to the primary and then
|
|
183
|
+
// reads `current`, which would stay unset because the primary's mint
|
|
184
|
+
// short-circuited past discovery. By construction we only reach here
|
|
185
|
+
// for the primary (linked districts return early above).
|
|
186
|
+
await this.discoverLinkedDistricts(account, session);
|
|
187
|
+
return session;
|
|
189
188
|
}
|
|
190
189
|
// fetchproxy mode: empty primary creds, can't post to verify.jsp.
|
|
191
190
|
// The user must re-sign-in in the browser (and the next process start
|
|
@@ -193,7 +192,7 @@ export class ICClient {
|
|
|
193
192
|
// and rethrows this instead of retrying a login that can never succeed.
|
|
194
193
|
if (!account.username || !account.password) {
|
|
195
194
|
throw new AuthFailedError(account.name, 'session expired and no IC_USERNAME/IC_PASSWORD set — ' +
|
|
196
|
-
'sign back into your IC portal in the browser
|
|
195
|
+
'sign back into your IC portal in the browser, or set IC_USERNAME/IC_PASSWORD', { permanent: true });
|
|
197
196
|
}
|
|
198
197
|
// ic_parent_api's pattern: single POST to verify.jsp, let the response
|
|
199
198
|
// set cookies. No pre-login GET needed (unlike OFW's Spring Security).
|
package/dist/index.js
CHANGED
|
@@ -37,13 +37,13 @@ const AI_NOTICE = '[infinitecampus-mcp] Developed and maintained by AI (Claude).
|
|
|
37
37
|
// IC portal in the browser). When not configured we register no tools and
|
|
38
38
|
// log a clear stderr message — far better than the previous crash loop.
|
|
39
39
|
let account = null;
|
|
40
|
-
let
|
|
40
|
+
let refreshSession;
|
|
41
41
|
let source;
|
|
42
42
|
let configError = null;
|
|
43
43
|
try {
|
|
44
44
|
const resolved = await resolveAuth();
|
|
45
45
|
account = resolved.account;
|
|
46
|
-
|
|
46
|
+
refreshSession = resolved.refresh;
|
|
47
47
|
source = resolved.source;
|
|
48
48
|
}
|
|
49
49
|
catch (e) {
|
|
@@ -57,10 +57,10 @@ catch (e) {
|
|
|
57
57
|
// actionable stderr message (banner) instead of a crash loop.
|
|
58
58
|
const COMMON = {
|
|
59
59
|
name: 'infinitecampus',
|
|
60
|
-
version: '2.4.
|
|
60
|
+
version: '2.4.4', // x-release-please-version
|
|
61
61
|
};
|
|
62
62
|
if (account) {
|
|
63
|
-
const client = new ICClient(account, {
|
|
63
|
+
const client = new ICClient(account, { refreshSession });
|
|
64
64
|
const tools = [
|
|
65
65
|
registerDistrictTools,
|
|
66
66
|
registerStudentTools,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinitecampus-mcp",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.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>",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@chrischall/mcp-utils": "^0.14.0",
|
|
46
|
-
"@fetchproxy/bootstrap": "^1.
|
|
46
|
+
"@fetchproxy/bootstrap": "^1.11.0",
|
|
47
47
|
"@fetchproxy/server": "^1.7.0",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"dotenv": "^17.4.2",
|
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.4.
|
|
9
|
+
"version": "2.4.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.4.
|
|
14
|
+
"version": "2.4.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|