infinitecampus-mcp 2.4.4 → 2.4.5

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.4.4"
10
+ "version": "2.4.5"
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.4",
18
+ "version": "2.4.5",
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",
4
+ "version": "2.4.5",
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/dist/bundle.js CHANGED
@@ -3651,7 +3651,12 @@ var require_fast_uri = __commonJS({
3651
3651
  }
3652
3652
  function resolve(baseURI, relativeURI, options) {
3653
3653
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3654
- const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3654
+ const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3655
+ const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
3656
+ if (baseMalformed || relativeMalformed) {
3657
+ throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
3658
+ }
3659
+ const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
3655
3660
  schemelessOptions.skipEscape = true;
3656
3661
  return serialize(resolved, schemelessOptions);
3657
3662
  }
@@ -3777,6 +3782,7 @@ var require_fast_uri = __commonJS({
3777
3782
  }
3778
3783
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3779
3784
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3785
+ var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
3780
3786
  function getParseError(parsed, matches) {
3781
3787
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3782
3788
  return 'URI path must start with "/" when authority is present.';
@@ -3811,6 +3817,20 @@ var require_fast_uri = __commonJS({
3811
3817
  parsed.error = "URI authority must not contain a literal backslash.";
3812
3818
  malformedAuthorityOrPort = true;
3813
3819
  }
3820
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
3821
+ if (introducerMatch !== null) {
3822
+ const region = introducerMatch[1];
3823
+ const normalizedRegion = region.replace(/[\t\n\r]/g, "");
3824
+ if (normalizedRegion.length >= 2) {
3825
+ if (normalizedRegion.slice(0, 2) !== "//") {
3826
+ parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
3827
+ malformedAuthorityOrPort = true;
3828
+ } else if (region.length !== normalizedRegion.length) {
3829
+ parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
3830
+ malformedAuthorityOrPort = true;
3831
+ }
3832
+ }
3833
+ }
3814
3834
  const matches = uri.match(URI_PARSE);
3815
3835
  if (matches) {
3816
3836
  parsed.scheme = matches[1];
@@ -10573,7 +10593,7 @@ var require_websocket_server = __commonJS({
10573
10593
  });
10574
10594
 
10575
10595
  // src/index.ts
10576
- import { dirname as dirname2, join as join2 } from "path";
10596
+ import { dirname as dirname2, join as join3 } from "path";
10577
10597
  import { fileURLToPath } from "url";
10578
10598
 
10579
10599
  // node_modules/zod/v3/helpers/util.js
@@ -34854,8 +34874,15 @@ var pageSchema = {
34854
34874
  };
34855
34875
 
34856
34876
  // node_modules/@fetchproxy/protocol/dist/frames.js
34857
- var PROTOCOL_VERSION = 2;
34877
+ var PROTOCOL_VERSION = 3;
34858
34878
  var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
34879
+ function readySignaturePayload(mcpHelloNonce, extHelloNonce, extensionSessionPub) {
34880
+ const out = new Uint8Array(mcpHelloNonce.length + extHelloNonce.length + extensionSessionPub.length);
34881
+ out.set(mcpHelloNonce, 0);
34882
+ out.set(extHelloNonce, mcpHelloNonce.length);
34883
+ out.set(extensionSessionPub, mcpHelloNonce.length + extHelloNonce.length);
34884
+ return out;
34885
+ }
34859
34886
  var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34860
34887
  "fetch",
34861
34888
  "read_cookies",
@@ -34866,7 +34893,8 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34866
34893
  "read_indexed_db",
34867
34894
  "read_dom",
34868
34895
  "download",
34869
- "graphql"
34896
+ "graphql",
34897
+ "write_cookies"
34870
34898
  ]);
34871
34899
 
34872
34900
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -35439,6 +35467,34 @@ function validateInnerRequest(raw) {
35439
35467
  }
35440
35468
  return raw;
35441
35469
  }
35470
+ if (raw.op === "write_cookies") {
35471
+ assertObject(raw.init, "inner.init");
35472
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35473
+ if (!Array.isArray(raw.init.cookies) || raw.init.cookies.length === 0) {
35474
+ throw new ProtocolError("inner.init.cookies: must be a non-empty array");
35475
+ }
35476
+ for (const [i, entry] of raw.init.cookies.entries()) {
35477
+ assertObject(entry, `inner.init.cookies[${i}]`);
35478
+ assertString(entry.name, `inner.init.cookies[${i}].name`);
35479
+ if (!SCOPE_KEY_RE.test(entry.name)) {
35480
+ throw new ProtocolError(`inner.init.cookies[${i}].name: invalid key ${JSON.stringify(entry.name)}`);
35481
+ }
35482
+ assertString(entry.value, `inner.init.cookies[${i}].value`);
35483
+ for (const k of Object.keys(entry)) {
35484
+ if (k !== "name" && k !== "value") {
35485
+ throw new ProtocolError(`inner.init.cookies[${i}]: unexpected field ${JSON.stringify(k)}`);
35486
+ }
35487
+ }
35488
+ }
35489
+ if (raw.init.path !== void 0)
35490
+ assertCookiePath(raw.init.path, "inner.init.path");
35491
+ for (const k of Object.keys(raw.init)) {
35492
+ if (k !== "origin" && k !== "cookies" && k !== "path") {
35493
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on write_cookies`);
35494
+ }
35495
+ }
35496
+ return raw;
35497
+ }
35442
35498
  if (raw.op === "read_local_storage" || raw.op === "read_session_storage") {
35443
35499
  assertObject(raw.init, "inner.init");
35444
35500
  if (raw.init.origin === void 0) {
@@ -35625,7 +35681,7 @@ function validateInnerRequest(raw) {
35625
35681
  }
35626
35682
  return raw;
35627
35683
  }
35628
- 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)}`);
35684
+ 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", "write_cookies"; got ${JSON.stringify(raw.op)}`);
35629
35685
  }
35630
35686
  function assertNonEmptyKeyArray(value, label) {
35631
35687
  if (!Array.isArray(value)) {
@@ -35686,6 +35742,18 @@ function validateInnerResponse(raw) {
35686
35742
  }
35687
35743
  return raw;
35688
35744
  }
35745
+ if (op === "write_cookies") {
35746
+ if (raw.written === void 0) {
35747
+ throw new ProtocolError("inner.written: missing on write_cookies response");
35748
+ }
35749
+ if (!Array.isArray(raw.written)) {
35750
+ throw new ProtocolError("inner.written: must be an array");
35751
+ }
35752
+ for (const [i, name] of raw.written.entries()) {
35753
+ assertString(name, `inner.written[${i}]`);
35754
+ }
35755
+ return raw;
35756
+ }
35689
35757
  if (op === "read_local_storage" || op === "read_session_storage") {
35690
35758
  if (raw.values === void 0) {
35691
35759
  throw new ProtocolError(`inner.values: missing on ${String(op)} response`);
@@ -36163,6 +36231,148 @@ async function awaitSessionReady(ready, opts) {
36163
36231
  }
36164
36232
  }
36165
36233
 
36234
+ // node_modules/@fetchproxy/server/dist/extension-trust.js
36235
+ import { readFile as readFile2, writeFile as writeFile2, rename, unlink, mkdir as mkdir2, chmod as chmod2 } from "node:fs/promises";
36236
+ import { join as join2 } from "node:path";
36237
+
36238
+ // node_modules/@fetchproxy/server/dist/identity.js
36239
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
36240
+ import { join } from "node:path";
36241
+ import { homedir } from "node:os";
36242
+ var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
36243
+ var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
36244
+ function defaultIdentityDir() {
36245
+ return join(homedir(), ".fetchproxy", "identity");
36246
+ }
36247
+ function safeIdentityFileBase(serverName) {
36248
+ if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
36249
+ throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
36250
+ }
36251
+ return serverName.replace(/\//g, "_");
36252
+ }
36253
+ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36254
+ const safeFile = safeIdentityFileBase(serverName);
36255
+ const path = join(dir, `${safeFile}.json`);
36256
+ await mkdir(dir, { recursive: true, mode: 448 });
36257
+ try {
36258
+ const raw = await readFile(path, "utf8");
36259
+ const j2 = JSON.parse(raw);
36260
+ return {
36261
+ x25519Priv: fromB64(j2.x25519Priv),
36262
+ x25519Pub: fromB64(j2.x25519Pub),
36263
+ ed25519Priv: fromB64(j2.ed25519Priv),
36264
+ ed25519Pub: fromB64(j2.ed25519Pub),
36265
+ createdAt: j2.createdAt
36266
+ };
36267
+ } catch (e) {
36268
+ if (e.code !== "ENOENT")
36269
+ throw e;
36270
+ }
36271
+ const x = await generateX25519();
36272
+ const ed = await generateEd25519();
36273
+ const id = {
36274
+ x25519Priv: x.privateKey,
36275
+ x25519Pub: x.publicKey,
36276
+ ed25519Priv: ed.privateKey,
36277
+ ed25519Pub: ed.publicKey,
36278
+ createdAt: Date.now()
36279
+ };
36280
+ const j = {
36281
+ x25519Priv: toB64(id.x25519Priv),
36282
+ x25519Pub: toB64(id.x25519Pub),
36283
+ ed25519Priv: toB64(id.ed25519Priv),
36284
+ ed25519Pub: toB64(id.ed25519Pub),
36285
+ createdAt: id.createdAt
36286
+ };
36287
+ await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
36288
+ await chmod(path, 384);
36289
+ return id;
36290
+ }
36291
+
36292
+ // node_modules/@fetchproxy/server/dist/extension-trust.js
36293
+ function fileExtensionTrust(args) {
36294
+ return {
36295
+ allowNew: args.allowNew,
36296
+ location: extensionTrustPath(args.serverName, args.dir ?? defaultIdentityDir()),
36297
+ read: () => readExtensionPin(args.serverName, args.dir ?? defaultIdentityDir()),
36298
+ write: (pin) => writeExtensionPin(args.serverName, pin, args.dir ?? defaultIdentityDir())
36299
+ };
36300
+ }
36301
+ var TRUST_NEW_EXTENSION_ENV = "FETCHPROXY_TRUST_NEW_EXTENSION";
36302
+ function allowNewExtensionIdentity(explicit, env = process.env) {
36303
+ if (explicit !== void 0)
36304
+ return explicit;
36305
+ return env[TRUST_NEW_EXTENSION_ENV] === "1";
36306
+ }
36307
+ function decideExtensionTrust(args) {
36308
+ const { pin, hello, allowNew, serverName } = args;
36309
+ if (!pin)
36310
+ return { decision: "first-use" };
36311
+ if (pin.identityX25519Pub === hello.identityX25519Pub && pin.identityEd25519Pub === hello.identityEd25519Pub) {
36312
+ return { decision: "pinned" };
36313
+ }
36314
+ const trustPath = args.location ?? extensionTrustPathHint(serverName);
36315
+ if (allowNew) {
36316
+ return {
36317
+ decision: "replace",
36318
+ message: `[fetchproxy] ${serverName}: accepting a NEW extension identity because ${TRUST_NEW_EXTENSION_ENV}=1 \u2014 re-pinning. Unset it once the browser you expect is connected.`
36319
+ };
36320
+ }
36321
+ return {
36322
+ decision: "refused",
36323
+ message: `[fetchproxy] ${serverName}: refusing an extension whose identity is not the one this MCP paired with. If you re-installed the extension or moved to another browser, re-pair deliberately: run this MCP once with ${TRUST_NEW_EXTENSION_ENV}=1, or delete ${trustPath}. If you did neither, something else is answering as your browser.`
36324
+ };
36325
+ }
36326
+ function extensionTrustPath(serverName, dir = defaultIdentityDir()) {
36327
+ return join2(dir, `${safeIdentityFileBase(serverName)}.extension-trust.json`);
36328
+ }
36329
+ function extensionTrustPathHint(serverName) {
36330
+ try {
36331
+ return extensionTrustPath(serverName);
36332
+ } catch {
36333
+ return join2(defaultIdentityDir(), "<server-name>.extension-trust.json");
36334
+ }
36335
+ }
36336
+ function isPin(x) {
36337
+ if (!x || typeof x !== "object")
36338
+ return false;
36339
+ const r = x;
36340
+ return typeof r.identityX25519Pub === "string" && typeof r.identityEd25519Pub === "string" && typeof r.pinnedAt === "number";
36341
+ }
36342
+ async function readExtensionPin(serverName, dir = defaultIdentityDir()) {
36343
+ const path = extensionTrustPath(serverName, dir);
36344
+ let raw;
36345
+ try {
36346
+ raw = await readFile2(path, "utf8");
36347
+ } catch (e) {
36348
+ if (e.code === "ENOENT")
36349
+ return null;
36350
+ throw e;
36351
+ }
36352
+ let parsed;
36353
+ try {
36354
+ parsed = JSON.parse(raw);
36355
+ } catch {
36356
+ throw new Error(`unreadable extension pin at ${path} (not JSON) \u2014 delete it to re-pair`);
36357
+ }
36358
+ if (!isPin(parsed)) {
36359
+ throw new Error(`unreadable extension pin at ${path} (wrong shape) \u2014 delete it to re-pair`);
36360
+ }
36361
+ return {
36362
+ identityX25519Pub: parsed.identityX25519Pub,
36363
+ identityEd25519Pub: parsed.identityEd25519Pub,
36364
+ pinnedAt: parsed.pinnedAt
36365
+ };
36366
+ }
36367
+ async function writeExtensionPin(serverName, pin, dir = defaultIdentityDir()) {
36368
+ const path = extensionTrustPath(serverName, dir);
36369
+ await mkdir2(dir, { recursive: true, mode: 448 });
36370
+ const tmp = `${path}.tmp`;
36371
+ await writeFile2(tmp, JSON.stringify(pin, null, 2), { mode: 384 });
36372
+ await chmod2(tmp, 384);
36373
+ await rename(tmp, path);
36374
+ }
36375
+
36166
36376
  // node_modules/@fetchproxy/server/dist/host.js
36167
36377
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
36168
36378
  var enc2 = new TextEncoder();
@@ -36216,9 +36426,12 @@ async function startHost(opts) {
36216
36426
  }
36217
36427
  resetSessionPromise();
36218
36428
  let extensionHello = null;
36429
+ let extensionClaim = null;
36219
36430
  wss.on("connection", (ws) => {
36220
36431
  let identified = null;
36221
36432
  let peerMcpId = null;
36433
+ let closed = false;
36434
+ let pinOnReady = false;
36222
36435
  ws.on("message", async (data) => {
36223
36436
  try {
36224
36437
  let frame;
@@ -36230,10 +36443,43 @@ async function startHost(opts) {
36230
36443
  return;
36231
36444
  }
36232
36445
  if (frame.type === "hello" && frame.role === "extension") {
36233
- if (extensionWs) {
36446
+ if (extensionWs || extensionClaim) {
36234
36447
  ws.close(1008, "extension already connected");
36235
36448
  return;
36236
36449
  }
36450
+ extensionClaim = ws;
36451
+ let pin;
36452
+ try {
36453
+ pin = await opts.extensionTrust.read();
36454
+ } catch (e) {
36455
+ console.error(`[fetchproxy] ${String(e)}`);
36456
+ if (extensionClaim === ws)
36457
+ extensionClaim = null;
36458
+ ws.close(1008, "extension pin unreadable");
36459
+ return;
36460
+ }
36461
+ const outcome = decideExtensionTrust({
36462
+ pin,
36463
+ hello: frame,
36464
+ allowNew: opts.extensionTrust.allowNew,
36465
+ serverName: opts.ownServerName,
36466
+ location: opts.extensionTrust.location
36467
+ });
36468
+ if (outcome.decision === "refused") {
36469
+ console.warn(outcome.message);
36470
+ if (extensionClaim === ws)
36471
+ extensionClaim = null;
36472
+ ws.close(1008, "extension identity is not the pinned one");
36473
+ return;
36474
+ }
36475
+ if (outcome.decision === "replace")
36476
+ console.warn(outcome.message);
36477
+ if (closed || ws.readyState !== import_websocket.default.OPEN) {
36478
+ if (extensionClaim === ws)
36479
+ extensionClaim = null;
36480
+ return;
36481
+ }
36482
+ pinOnReady = outcome.decision !== "pinned";
36237
36483
  identified = "extension";
36238
36484
  extensionWs = ws;
36239
36485
  extensionHello = frame;
@@ -36245,6 +36491,8 @@ async function startHost(opts) {
36245
36491
  console.error("[fetchproxy] onPairCode threw:", e);
36246
36492
  }
36247
36493
  }
36494
+ for (const slot of peers.values())
36495
+ slot.ws.send(JSON.stringify(frame));
36248
36496
  ws.send(JSON.stringify(ownHello));
36249
36497
  for (const slot of peers.values()) {
36250
36498
  ws.send(JSON.stringify(slot.helloFrame));
@@ -36280,6 +36528,8 @@ async function startHost(opts) {
36280
36528
  peers.set(frame.mcpId, { ws, helloFrame: frame });
36281
36529
  if (extensionWs)
36282
36530
  extensionWs.send(JSON.stringify(frame));
36531
+ if (extensionHello)
36532
+ ws.send(JSON.stringify(extensionHello));
36283
36533
  return;
36284
36534
  }
36285
36535
  if (frame.type === "ready") {
@@ -36291,7 +36541,7 @@ async function startHost(opts) {
36291
36541
  }
36292
36542
  const extEdPub = fromB64(extensionHello.identityEd25519Pub);
36293
36543
  const extNonce = fromB64(extensionHello.sessionNonce);
36294
- const msg = concatBytes(ownSessionNonce, extNonce);
36544
+ const msg = readySignaturePayload(ownSessionNonce, extNonce, fromB64(frame.extensionSessionPub));
36295
36545
  const sig = fromB64(frame.sessionSig);
36296
36546
  let sigOk = false;
36297
36547
  try {
@@ -36304,6 +36554,18 @@ async function startHost(opts) {
36304
36554
  ws.close(1008, "extension session signature invalid");
36305
36555
  return;
36306
36556
  }
36557
+ if (pinOnReady) {
36558
+ pinOnReady = false;
36559
+ try {
36560
+ await opts.extensionTrust.write({
36561
+ identityX25519Pub: extensionHello.identityX25519Pub,
36562
+ identityEd25519Pub: extensionHello.identityEd25519Pub,
36563
+ pinnedAt: Date.now()
36564
+ });
36565
+ } catch (e) {
36566
+ console.error(`[fetchproxy] could not persist the extension pin: ${String(e)}`);
36567
+ }
36568
+ }
36307
36569
  const extPub = fromB64(frame.extensionSessionPub);
36308
36570
  const shared = await ecdhX25519(opts.ownIdentity.x25519Priv, extPub);
36309
36571
  const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode(HKDF_SESSION_INFO), 32);
@@ -36357,6 +36619,9 @@ async function startHost(opts) {
36357
36619
  }
36358
36620
  });
36359
36621
  ws.on("close", () => {
36622
+ closed = true;
36623
+ if (extensionClaim === ws)
36624
+ extensionClaim = null;
36360
36625
  if (identified === "extension" && extensionWs === ws) {
36361
36626
  extensionWs = null;
36362
36627
  extensionHello = null;
@@ -36447,11 +36712,84 @@ async function startPeer(opts) {
36447
36712
  resolveFirstReady = resolve;
36448
36713
  rejectFirstReady = reject;
36449
36714
  });
36715
+ let extensionHello = null;
36716
+ let warnedUnverifiable = false;
36717
+ let cachedPin = void 0;
36718
+ const authenticateExtension = async (sessionSig, extensionSessionPub) => {
36719
+ if (!extensionHello) {
36720
+ if (opts.requireExtensionIdentity) {
36721
+ console.error(`[fetchproxy] ${opts.serverName}: the concentrator does not forward the extension's identity, so this session cannot be verified \u2014 refusing. Upgrade the MCP holding the bridge port to 1.12.0 or later.`);
36722
+ return false;
36723
+ }
36724
+ if (!warnedUnverifiable) {
36725
+ warnedUnverifiable = true;
36726
+ console.warn(`[fetchproxy] ${opts.serverName}: the concentrator does not forward the extension's identity (pre-1.12.0), so this peer cannot verify which browser it is talking to. Upgrade the MCP holding the bridge port to close this.`);
36727
+ }
36728
+ return true;
36729
+ }
36730
+ const payload = readySignaturePayload(sessionNonce, fromB64(extensionHello.sessionNonce), fromB64(extensionSessionPub));
36731
+ let sigOk = false;
36732
+ try {
36733
+ sigOk = await ed25519Verify(fromB64(extensionHello.identityEd25519Pub), payload, fromB64(sessionSig));
36734
+ } catch {
36735
+ sigOk = false;
36736
+ }
36737
+ if (!sigOk) {
36738
+ console.warn(`[fetchproxy] ${opts.serverName}: extension session signature invalid \u2014 refusing (the concentrator may be answering in the browser's place)`);
36739
+ return false;
36740
+ }
36741
+ if (cachedPin === void 0) {
36742
+ try {
36743
+ cachedPin = await opts.extensionTrust.read();
36744
+ } catch (e) {
36745
+ console.error(`[fetchproxy] ${String(e)}`);
36746
+ return false;
36747
+ }
36748
+ }
36749
+ const pin = cachedPin;
36750
+ const outcome = decideExtensionTrust({
36751
+ pin,
36752
+ hello: extensionHello,
36753
+ allowNew: opts.extensionTrust.allowNew,
36754
+ serverName: opts.serverName,
36755
+ location: opts.extensionTrust.location
36756
+ });
36757
+ if (outcome.decision === "refused") {
36758
+ console.warn(outcome.message);
36759
+ return false;
36760
+ }
36761
+ if (outcome.decision === "replace")
36762
+ console.warn(outcome.message);
36763
+ if (outcome.decision !== "pinned") {
36764
+ try {
36765
+ const written = {
36766
+ identityX25519Pub: extensionHello.identityX25519Pub,
36767
+ identityEd25519Pub: extensionHello.identityEd25519Pub,
36768
+ pinnedAt: Date.now()
36769
+ };
36770
+ await opts.extensionTrust.write(written);
36771
+ cachedPin = written;
36772
+ } catch (e) {
36773
+ console.error(`[fetchproxy] could not persist the extension pin: ${String(e)}`);
36774
+ }
36775
+ }
36776
+ return true;
36777
+ };
36450
36778
  const onMessage = async (data) => {
36451
36779
  try {
36452
36780
  const raw = JSON.parse(data.toString());
36453
36781
  const frame = validateFrame(raw);
36782
+ if (frame.type === "hello" && frame.role === "extension") {
36783
+ extensionHello = frame;
36784
+ return;
36785
+ }
36454
36786
  if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
36787
+ const authorised = await authenticateExtension(frame.sessionSig, frame.extensionSessionPub);
36788
+ if (!authorised) {
36789
+ ws.close(1008, "extension identity refused");
36790
+ rejectFirstReady(new Error("peer: extension identity refused"));
36791
+ return;
36792
+ }
36455
36793
  const extPub = fromB64(frame.extensionSessionPub);
36456
36794
  const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
36457
36795
  const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
@@ -36532,57 +36870,6 @@ async function startPeer(opts) {
36532
36870
  return handle;
36533
36871
  }
36534
36872
 
36535
- // node_modules/@fetchproxy/server/dist/identity.js
36536
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
36537
- import { join } from "node:path";
36538
- import { homedir } from "node:os";
36539
- var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
36540
- var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
36541
- function defaultIdentityDir() {
36542
- return join(homedir(), ".fetchproxy", "identity");
36543
- }
36544
- async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36545
- if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
36546
- throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
36547
- }
36548
- const safeFile = serverName.replace(/\//g, "_");
36549
- const path = join(dir, `${safeFile}.json`);
36550
- await mkdir(dir, { recursive: true, mode: 448 });
36551
- try {
36552
- const raw = await readFile(path, "utf8");
36553
- const j2 = JSON.parse(raw);
36554
- return {
36555
- x25519Priv: fromB64(j2.x25519Priv),
36556
- x25519Pub: fromB64(j2.x25519Pub),
36557
- ed25519Priv: fromB64(j2.ed25519Priv),
36558
- ed25519Pub: fromB64(j2.ed25519Pub),
36559
- createdAt: j2.createdAt
36560
- };
36561
- } catch (e) {
36562
- if (e.code !== "ENOENT")
36563
- throw e;
36564
- }
36565
- const x = await generateX25519();
36566
- const ed = await generateEd25519();
36567
- const id = {
36568
- x25519Priv: x.privateKey,
36569
- x25519Pub: x.publicKey,
36570
- ed25519Priv: ed.privateKey,
36571
- ed25519Pub: ed.publicKey,
36572
- createdAt: Date.now()
36573
- };
36574
- const j = {
36575
- x25519Priv: toB64(id.x25519Priv),
36576
- x25519Pub: toB64(id.x25519Pub),
36577
- ed25519Priv: toB64(id.ed25519Priv),
36578
- ed25519Pub: toB64(id.ed25519Pub),
36579
- createdAt: id.createdAt
36580
- };
36581
- await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
36582
- await chmod(path, 384);
36583
- return id;
36584
- }
36585
-
36586
36873
  // node_modules/@fetchproxy/server/dist/error-kind.js
36587
36874
  function classifyFetchError(error51) {
36588
36875
  if (/Could not establish connection/i.test(error51) || /Receiving end does not exist/i.test(error51)) {
@@ -36664,21 +36951,38 @@ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
36664
36951
  this.hint = hint;
36665
36952
  }
36666
36953
  };
36667
- var FetchproxyScopeError = class extends FetchproxyProtocolError {
36954
+ var FetchproxyHintedError = class extends FetchproxyProtocolError {
36668
36955
  /** The extension's raw rejection, unmodified. */
36669
36956
  originalError;
36957
+ /** What the user should actually do, in prose. */
36670
36958
  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.";
36959
+ constructor(originalError, hint) {
36673
36960
  super(`${originalError} \u2014 ${hint}`);
36674
- this.name = "FetchproxyScopeError";
36961
+ this.name = "FetchproxyHintedError";
36675
36962
  this.originalError = originalError;
36676
36963
  this.hint = hint;
36677
36964
  }
36678
36965
  };
36966
+ var FetchproxyScopeError = class extends FetchproxyHintedError {
36967
+ constructor(originalError) {
36968
+ super(originalError, "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.");
36969
+ this.name = "FetchproxyScopeError";
36970
+ }
36971
+ };
36972
+ var FetchproxyNoTabError = class extends FetchproxyHintedError {
36973
+ constructor(originalError) {
36974
+ super(originalError, "open a tab on that host and sign in, then re-run. This is not a version problem and does not need an update.");
36975
+ this.name = "FetchproxyNoTabError";
36976
+ }
36977
+ };
36679
36978
  var SCOPE_REJECTION = /not in declared/;
36979
+ var NO_TAB_REJECTION = /no tab matching (?!.*content script loaded)/;
36680
36980
  function protocolErrorFrom(error51) {
36681
- return SCOPE_REJECTION.test(error51) ? new FetchproxyScopeError(error51) : new FetchproxyProtocolError(error51);
36981
+ if (SCOPE_REJECTION.test(error51))
36982
+ return new FetchproxyScopeError(error51);
36983
+ if (NO_TAB_REJECTION.test(error51))
36984
+ return new FetchproxyNoTabError(error51);
36985
+ return new FetchproxyProtocolError(error51);
36682
36986
  }
36683
36987
  var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36684
36988
  url;
@@ -36784,6 +37088,9 @@ var FetchproxyServer = class {
36784
37088
  // them off from `pending` (fetch) and `pendingReadCookies` (legacy
36785
37089
  // string-shape) so the response routing in `onInner` stays linear.
36786
37090
  pendingStorage = /* @__PURE__ */ new Map();
37091
+ // 1.12.0+: write-cookies awaiters resolve the list of names actually
37092
+ // written, so a caller can confirm rather than assume.
37093
+ pendingWriteCookies = /* @__PURE__ */ new Map();
36787
37094
  // 0.3.0+: capture-header awaiters resolve a single string.
36788
37095
  pendingCapture = /* @__PURE__ */ new Map();
36789
37096
  // capture_redirect awaiters resolve the captured redirect URL string.
@@ -36910,6 +37217,8 @@ var FetchproxyServer = class {
36910
37217
  keepAliveIntervalMs: opts.keepAliveIntervalMs ?? 2e4,
36911
37218
  keepAliveMaxIdleMs: opts.keepAliveMaxIdleMs ?? 5 * 60 * 1e3,
36912
37219
  identityDir: opts.identityDir,
37220
+ allowNewExtensionIdentity: opts.allowNewExtensionIdentity,
37221
+ requireExtensionIdentity: opts.requireExtensionIdentity,
36913
37222
  onPairCode: opts.onPairCode
36914
37223
  };
36915
37224
  }
@@ -37009,7 +37318,8 @@ var FetchproxyServer = class {
37009
37318
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
37010
37319
  ownDomSelectors: this.opts.domSelectors,
37011
37320
  ownGraphqlOps: this.opts.graphqlOps,
37012
- onPairCode: this.opts.onPairCode
37321
+ onPairCode: this.opts.onPairCode,
37322
+ extensionTrust: this.extensionTrust()
37013
37323
  });
37014
37324
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
37015
37325
  this.hostHandle.onExtensionDisconnect(() => {
@@ -37038,7 +37348,9 @@ var FetchproxyServer = class {
37038
37348
  localStoragePointers: this.opts.localStoragePointers,
37039
37349
  sessionStoragePointers: this.opts.sessionStoragePointers,
37040
37350
  domSelectors: this.opts.domSelectors,
37041
- graphqlOps: this.opts.graphqlOps
37351
+ graphqlOps: this.opts.graphqlOps,
37352
+ extensionTrust: this.extensionTrust(),
37353
+ requireExtensionIdentity: this.opts.requireExtensionIdentity
37042
37354
  });
37043
37355
  this.peerHandle.onInner((inner) => this.onInner(inner));
37044
37356
  this.peerHandle.onRenegotiate(() => {
@@ -37177,6 +37489,23 @@ var FetchproxyServer = class {
37177
37489
  markActive() {
37178
37490
  this.noteActivityForKeepalive();
37179
37491
  }
37492
+ /**
37493
+ * #208: this MCP's pin on the extension's identity, stored beside its own
37494
+ * identity key and so following `identityDir` wherever the caller put it.
37495
+ *
37496
+ * `allowNewExtensionIdentity` falls back to an environment variable when the
37497
+ * caller expressed no opinion, because the thirteen MCPs that construct this
37498
+ * class are separate packages: an operator whose extension re-install has
37499
+ * just locked all of them out needs one lever that does not require patching
37500
+ * every one of them.
37501
+ */
37502
+ extensionTrust() {
37503
+ return fileExtensionTrust({
37504
+ serverName: this.opts.serverName,
37505
+ dir: this.opts.identityDir,
37506
+ allowNew: allowNewExtensionIdentity(this.opts.allowNewExtensionIdentity)
37507
+ });
37508
+ }
37180
37509
  noteActivityForKeepalive() {
37181
37510
  const intervalMs = this.opts.keepAliveIntervalMs;
37182
37511
  if (intervalMs <= 0)
@@ -37240,6 +37569,7 @@ var FetchproxyServer = class {
37240
37569
  this.pending.delete(id);
37241
37570
  this.pendingReadCookies.delete(id);
37242
37571
  this.pendingStorage.delete(id);
37572
+ this.pendingWriteCookies.delete(id);
37243
37573
  this.pendingCapture.delete(id);
37244
37574
  this.pendingRedirect.delete(id);
37245
37575
  this.pendingDownload.delete(id);
@@ -37397,10 +37727,20 @@ var FetchproxyServer = class {
37397
37727
  }
37398
37728
  const url2 = isAbsolute ? path : `https://${host}${path}`;
37399
37729
  assertUrlInDomains("request url", url2, this.opts.domains);
37730
+ let tabUrl = `https://${host}/`;
37731
+ if (opts.viaTab !== void 0) {
37732
+ try {
37733
+ new URL(opts.viaTab);
37734
+ } catch {
37735
+ throw new Error(`FetchproxyServer.request: viaTab is not a valid URL: ${JSON.stringify(opts.viaTab)}`);
37736
+ }
37737
+ assertUrlInDomains("viaTab", opts.viaTab, this.opts.domains);
37738
+ tabUrl = opts.viaTab;
37739
+ }
37400
37740
  const init = {
37401
37741
  url: url2,
37402
37742
  method,
37403
- tabUrl: `https://${host}/`,
37743
+ tabUrl,
37404
37744
  headers: opts.headers,
37405
37745
  body: opts.body
37406
37746
  };
@@ -37636,6 +37976,61 @@ var FetchproxyServer = class {
37636
37976
  }
37637
37977
  return result.cookies;
37638
37978
  }
37979
+ /**
37980
+ * 1.12.0+: overwrite the value of cookies this MCP already declares.
37981
+ *
37982
+ * The bridge's only write verb, and it exists for one failure class. Sites
37983
+ * that ROTATE a credential cookie hand back a new value on every refresh; if
37984
+ * the MCP refreshes and keeps the result to itself, the copy in the browser's
37985
+ * cookie jar is dead, and the user gets signed out of a tab they never
37986
+ * touched — usually reported to them as "inactivity". Writing the rotated
37987
+ * value back is the only thing that repairs it.
37988
+ *
37989
+ * Requires `'write_cookies'` in capabilities, which the user approves at pair
37990
+ * time as its own line. Every name must ALSO be in declared `cookieKeys`: a
37991
+ * write can never reach a cookie the MCP was not already trusted to read, so
37992
+ * granting it cannot widen which cookies are in play — only what may be done
37993
+ * to the ones already listed.
37994
+ *
37995
+ * The extension refuses the whole request unless every named cookie already
37996
+ * exists; this refreshes a value in place and deliberately cannot author new
37997
+ * cookies. Returns the names actually written.
37998
+ */
37999
+ async writeCookies(opts) {
38000
+ if (!this.opts.capabilities.includes("write_cookies")) {
38001
+ throw new Error('FetchproxyServer.writeCookies(): MCP did not declare "write_cookies" in capabilities \u2014 add it to FetchproxyServerOpts.capabilities to enable this verb');
38002
+ }
38003
+ const names = Object.keys(opts.cookies);
38004
+ if (names.length === 0) {
38005
+ throw new Error("FetchproxyServer.writeCookies(): no cookies given");
38006
+ }
38007
+ await this.ensureConnected();
38008
+ this.throwIfPendingPair();
38009
+ if (opts.subdomain !== void 0)
38010
+ assertSubdomainLabel(opts.subdomain);
38011
+ const baseDomain = this.resolveBaseDomain(opts.domain);
38012
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
38013
+ this.assertScopeSubset(names, this.opts.cookieKeys, "cookieKeys");
38014
+ const cookiePath = normalizeCookiePath(opts.path);
38015
+ const id = this.nextRequestId++;
38016
+ const inner = {
38017
+ type: "request",
38018
+ id,
38019
+ op: "write_cookies",
38020
+ init: {
38021
+ // Bare origin, same invariant as the read path: a path must never be
38022
+ // able to move the request past the domain gate.
38023
+ origin: `https://${host}`,
38024
+ cookies: Object.entries(opts.cookies).map(([name, value]) => ({ name, value })),
38025
+ ...cookiePath !== void 0 ? { path: cookiePath } : {}
38026
+ }
38027
+ };
38028
+ const pending = new Promise((resolve, reject) => {
38029
+ this.pendingWriteCookies.set(id, { resolve, reject });
38030
+ });
38031
+ await this.sendInnerFrame(inner);
38032
+ return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
38033
+ }
37639
38034
  /**
37640
38035
  * 0.3.0+: read declared localStorage keys from the user's signed-in
37641
38036
  * tab. Requires `'read_local_storage'` in capabilities AND each key
@@ -38256,6 +38651,16 @@ var FetchproxyServer = class {
38256
38651
  }
38257
38652
  return;
38258
38653
  }
38654
+ const writeCookiesCb = this.pendingWriteCookies.get(inner.id);
38655
+ if (writeCookiesCb) {
38656
+ this.pendingWriteCookies.delete(inner.id);
38657
+ if (inner.ok && inner.op === "write_cookies") {
38658
+ writeCookiesCb.resolve([...inner.written]);
38659
+ } else {
38660
+ writeCookiesCb.reject(protocolErrorFrom(inner.ok ? "write_cookies response had the wrong op" : inner.error));
38661
+ }
38662
+ return;
38663
+ }
38259
38664
  const cookiesCb = this.pendingReadCookies.get(inner.id);
38260
38665
  if (cookiesCb) {
38261
38666
  this.pendingReadCookies.delete(inner.id);
@@ -38295,6 +38700,9 @@ var FetchproxyServer = class {
38295
38700
  for (const { reject } of this.pendingStorage.values())
38296
38701
  reject(err);
38297
38702
  this.pendingStorage.clear();
38703
+ for (const { reject } of this.pendingWriteCookies.values())
38704
+ reject(err);
38705
+ this.pendingWriteCookies.clear();
38298
38706
  for (const { reject } of this.pendingCapture.values())
38299
38707
  reject(err);
38300
38708
  this.pendingCapture.clear();
@@ -38577,7 +38985,7 @@ function loadAccount(env = process.env) {
38577
38985
  // package.json
38578
38986
  var package_default = {
38579
38987
  name: "infinitecampus-mcp",
38580
- version: "2.4.4",
38988
+ version: "2.4.5",
38581
38989
  mcpName: "io.github.chrischall/infinitecampus-mcp",
38582
38990
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
38583
38991
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38620,8 +39028,8 @@ var package_default = {
38620
39028
  },
38621
39029
  dependencies: {
38622
39030
  "@chrischall/mcp-utils": "^0.14.0",
38623
- "@fetchproxy/bootstrap": "^1.11.0",
38624
- "@fetchproxy/server": "^1.7.0",
39031
+ "@fetchproxy/bootstrap": "^2.0.0",
39032
+ "@fetchproxy/server": "^2.0.0",
38625
39033
  "@modelcontextprotocol/sdk": "^1.29.0",
38626
39034
  dotenv: "^17.4.2",
38627
39035
  "node-html-parser": "^9.0.0",
@@ -38729,7 +39137,7 @@ async function liftBrowserSession(account2) {
38729
39137
  }
38730
39138
 
38731
39139
  // src/client.ts
38732
- import { writeFile as writeFile2, stat } from "fs/promises";
39140
+ import { writeFile as writeFile3, stat } from "fs/promises";
38733
39141
  import { dirname } from "path";
38734
39142
 
38735
39143
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
@@ -39189,7 +39597,7 @@ var ICClient = class {
39189
39597
  });
39190
39598
  if (!res.ok) throw new Error(`IC download ${res.status} for ${path}`);
39191
39599
  const buf = new Uint8Array(await res.arrayBuffer());
39192
- await writeFile2(destinationPath, buf);
39600
+ await writeFile3(destinationPath, buf);
39193
39601
  return {
39194
39602
  path: destinationPath,
39195
39603
  bytes: buf.byteLength,
@@ -40197,7 +40605,7 @@ function registerFeaturesTools(server, client) {
40197
40605
 
40198
40606
  // src/index.ts
40199
40607
  await loadDotenvSafely({
40200
- path: join2(dirname2(fileURLToPath(import.meta.url)), "..", ".env"),
40608
+ path: join3(dirname2(fileURLToPath(import.meta.url)), "..", ".env"),
40201
40609
  override: false
40202
40610
  });
40203
40611
  var AI_NOTICE = "[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.";
@@ -40215,7 +40623,7 @@ try {
40215
40623
  }
40216
40624
  var COMMON = {
40217
40625
  name: "infinitecampus",
40218
- version: "2.4.4"
40626
+ version: "2.4.5"
40219
40627
  // x-release-please-version
40220
40628
  };
40221
40629
  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.4.4', // x-release-please-version
60
+ version: '2.4.5', // x-release-please-version
61
61
  };
62
62
  if (account) {
63
63
  const client = new ICClient(account, { refreshSession });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
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,8 +43,8 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@chrischall/mcp-utils": "^0.14.0",
46
- "@fetchproxy/bootstrap": "^1.11.0",
47
- "@fetchproxy/server": "^1.7.0",
46
+ "@fetchproxy/bootstrap": "^2.0.0",
47
+ "@fetchproxy/server": "^2.0.0",
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "dotenv": "^17.4.2",
50
50
  "node-html-parser": "^9.0.0",
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.4",
9
+ "version": "2.4.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.4.4",
14
+ "version": "2.4.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },