infinitecampus-mcp 2.4.3 → 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.3"
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.3",
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.3",
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/auth.js CHANGED
@@ -115,6 +115,18 @@ function lifterFor(host) {
115
115
  // <district>.infinitecampus.org, etc.). Declare just this host
116
116
  // — each district is its own root, no wildcard needed.
117
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',
118
130
  declare: {
119
131
  // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
120
132
  // security gate is the declared key list). XSRF-TOKEN is
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
@@ -34977,6 +35005,15 @@ function assertHttpUrl(x, label) {
34977
35005
  throw new ProtocolError(`${label}: must be http(s), got ${u.protocol}`);
34978
35006
  }
34979
35007
  }
35008
+ function assertCookiePath(x, label) {
35009
+ assertString(x, label);
35010
+ if (!x.startsWith("/") || x.startsWith("//")) {
35011
+ throw new ProtocolError(`${label}: must be an absolute path like "/campus"`);
35012
+ }
35013
+ if (x.includes("?") || x.includes("#") || x.includes("\\")) {
35014
+ throw new ProtocolError(`${label}: must not contain a query, fragment, or backslash`);
35015
+ }
35016
+ }
34980
35017
  function assertHttpsOriginOnly(x, label) {
34981
35018
  assertString(x, label);
34982
35019
  let u;
@@ -35421,13 +35458,43 @@ function validateInnerRequest(raw) {
35421
35458
  }
35422
35459
  assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35423
35460
  assertNonEmptyKeyArray(raw.init.keys, "inner.init.keys");
35461
+ if (raw.init.path !== void 0)
35462
+ assertCookiePath(raw.init.path, "inner.init.path");
35424
35463
  for (const k of Object.keys(raw.init)) {
35425
- if (k !== "origin" && k !== "keys") {
35464
+ if (k !== "origin" && k !== "keys" && k !== "path") {
35426
35465
  throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_cookies`);
35427
35466
  }
35428
35467
  }
35429
35468
  return raw;
35430
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
+ }
35431
35498
  if (raw.op === "read_local_storage" || raw.op === "read_session_storage") {
35432
35499
  assertObject(raw.init, "inner.init");
35433
35500
  if (raw.init.origin === void 0) {
@@ -35614,7 +35681,7 @@ function validateInnerRequest(raw) {
35614
35681
  }
35615
35682
  return raw;
35616
35683
  }
35617
- 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)}`);
35618
35685
  }
35619
35686
  function assertNonEmptyKeyArray(value, label) {
35620
35687
  if (!Array.isArray(value)) {
@@ -35675,6 +35742,18 @@ function validateInnerResponse(raw) {
35675
35742
  }
35676
35743
  return raw;
35677
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
+ }
35678
35757
  if (op === "read_local_storage" || op === "read_session_storage") {
35679
35758
  if (raw.values === void 0) {
35680
35759
  throw new ProtocolError(`inner.values: missing on ${String(op)} response`);
@@ -36152,6 +36231,148 @@ async function awaitSessionReady(ready, opts) {
36152
36231
  }
36153
36232
  }
36154
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
+
36155
36376
  // node_modules/@fetchproxy/server/dist/host.js
36156
36377
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
36157
36378
  var enc2 = new TextEncoder();
@@ -36205,9 +36426,12 @@ async function startHost(opts) {
36205
36426
  }
36206
36427
  resetSessionPromise();
36207
36428
  let extensionHello = null;
36429
+ let extensionClaim = null;
36208
36430
  wss.on("connection", (ws) => {
36209
36431
  let identified = null;
36210
36432
  let peerMcpId = null;
36433
+ let closed = false;
36434
+ let pinOnReady = false;
36211
36435
  ws.on("message", async (data) => {
36212
36436
  try {
36213
36437
  let frame;
@@ -36219,10 +36443,43 @@ async function startHost(opts) {
36219
36443
  return;
36220
36444
  }
36221
36445
  if (frame.type === "hello" && frame.role === "extension") {
36222
- if (extensionWs) {
36446
+ if (extensionWs || extensionClaim) {
36223
36447
  ws.close(1008, "extension already connected");
36224
36448
  return;
36225
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";
36226
36483
  identified = "extension";
36227
36484
  extensionWs = ws;
36228
36485
  extensionHello = frame;
@@ -36234,6 +36491,8 @@ async function startHost(opts) {
36234
36491
  console.error("[fetchproxy] onPairCode threw:", e);
36235
36492
  }
36236
36493
  }
36494
+ for (const slot of peers.values())
36495
+ slot.ws.send(JSON.stringify(frame));
36237
36496
  ws.send(JSON.stringify(ownHello));
36238
36497
  for (const slot of peers.values()) {
36239
36498
  ws.send(JSON.stringify(slot.helloFrame));
@@ -36269,6 +36528,8 @@ async function startHost(opts) {
36269
36528
  peers.set(frame.mcpId, { ws, helloFrame: frame });
36270
36529
  if (extensionWs)
36271
36530
  extensionWs.send(JSON.stringify(frame));
36531
+ if (extensionHello)
36532
+ ws.send(JSON.stringify(extensionHello));
36272
36533
  return;
36273
36534
  }
36274
36535
  if (frame.type === "ready") {
@@ -36280,7 +36541,7 @@ async function startHost(opts) {
36280
36541
  }
36281
36542
  const extEdPub = fromB64(extensionHello.identityEd25519Pub);
36282
36543
  const extNonce = fromB64(extensionHello.sessionNonce);
36283
- const msg = concatBytes(ownSessionNonce, extNonce);
36544
+ const msg = readySignaturePayload(ownSessionNonce, extNonce, fromB64(frame.extensionSessionPub));
36284
36545
  const sig = fromB64(frame.sessionSig);
36285
36546
  let sigOk = false;
36286
36547
  try {
@@ -36293,6 +36554,18 @@ async function startHost(opts) {
36293
36554
  ws.close(1008, "extension session signature invalid");
36294
36555
  return;
36295
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
+ }
36296
36569
  const extPub = fromB64(frame.extensionSessionPub);
36297
36570
  const shared = await ecdhX25519(opts.ownIdentity.x25519Priv, extPub);
36298
36571
  const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode(HKDF_SESSION_INFO), 32);
@@ -36346,6 +36619,9 @@ async function startHost(opts) {
36346
36619
  }
36347
36620
  });
36348
36621
  ws.on("close", () => {
36622
+ closed = true;
36623
+ if (extensionClaim === ws)
36624
+ extensionClaim = null;
36349
36625
  if (identified === "extension" && extensionWs === ws) {
36350
36626
  extensionWs = null;
36351
36627
  extensionHello = null;
@@ -36436,11 +36712,84 @@ async function startPeer(opts) {
36436
36712
  resolveFirstReady = resolve;
36437
36713
  rejectFirstReady = reject;
36438
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
+ };
36439
36778
  const onMessage = async (data) => {
36440
36779
  try {
36441
36780
  const raw = JSON.parse(data.toString());
36442
36781
  const frame = validateFrame(raw);
36782
+ if (frame.type === "hello" && frame.role === "extension") {
36783
+ extensionHello = frame;
36784
+ return;
36785
+ }
36443
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
+ }
36444
36793
  const extPub = fromB64(frame.extensionSessionPub);
36445
36794
  const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
36446
36795
  const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
@@ -36521,57 +36870,6 @@ async function startPeer(opts) {
36521
36870
  return handle;
36522
36871
  }
36523
36872
 
36524
- // node_modules/@fetchproxy/server/dist/identity.js
36525
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
36526
- import { join } from "node:path";
36527
- import { homedir } from "node:os";
36528
- var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
36529
- var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
36530
- function defaultIdentityDir() {
36531
- return join(homedir(), ".fetchproxy", "identity");
36532
- }
36533
- async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36534
- if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
36535
- throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
36536
- }
36537
- const safeFile = serverName.replace(/\//g, "_");
36538
- const path = join(dir, `${safeFile}.json`);
36539
- await mkdir(dir, { recursive: true, mode: 448 });
36540
- try {
36541
- const raw = await readFile(path, "utf8");
36542
- const j2 = JSON.parse(raw);
36543
- return {
36544
- x25519Priv: fromB64(j2.x25519Priv),
36545
- x25519Pub: fromB64(j2.x25519Pub),
36546
- ed25519Priv: fromB64(j2.ed25519Priv),
36547
- ed25519Pub: fromB64(j2.ed25519Pub),
36548
- createdAt: j2.createdAt
36549
- };
36550
- } catch (e) {
36551
- if (e.code !== "ENOENT")
36552
- throw e;
36553
- }
36554
- const x = await generateX25519();
36555
- const ed = await generateEd25519();
36556
- const id = {
36557
- x25519Priv: x.privateKey,
36558
- x25519Pub: x.publicKey,
36559
- ed25519Priv: ed.privateKey,
36560
- ed25519Pub: ed.publicKey,
36561
- createdAt: Date.now()
36562
- };
36563
- const j = {
36564
- x25519Priv: toB64(id.x25519Priv),
36565
- x25519Pub: toB64(id.x25519Pub),
36566
- ed25519Priv: toB64(id.ed25519Priv),
36567
- ed25519Pub: toB64(id.ed25519Pub),
36568
- createdAt: id.createdAt
36569
- };
36570
- await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
36571
- await chmod(path, 384);
36572
- return id;
36573
- }
36574
-
36575
36873
  // node_modules/@fetchproxy/server/dist/error-kind.js
36576
36874
  function classifyFetchError(error51) {
36577
36875
  if (/Could not establish connection/i.test(error51) || /Receiving end does not exist/i.test(error51)) {
@@ -36653,6 +36951,39 @@ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
36653
36951
  this.hint = hint;
36654
36952
  }
36655
36953
  };
36954
+ var FetchproxyHintedError = class extends FetchproxyProtocolError {
36955
+ /** The extension's raw rejection, unmodified. */
36956
+ originalError;
36957
+ /** What the user should actually do, in prose. */
36958
+ hint;
36959
+ constructor(originalError, hint) {
36960
+ super(`${originalError} \u2014 ${hint}`);
36961
+ this.name = "FetchproxyHintedError";
36962
+ this.originalError = originalError;
36963
+ this.hint = hint;
36964
+ }
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
+ };
36978
+ var SCOPE_REJECTION = /not in declared/;
36979
+ var NO_TAB_REJECTION = /no tab matching (?!.*content script loaded)/;
36980
+ function protocolErrorFrom(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);
36986
+ }
36656
36987
  var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36657
36988
  url;
36658
36989
  timeoutMs;
@@ -36682,6 +37013,17 @@ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36682
37013
  }
36683
37014
  };
36684
37015
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
37016
+ function normalizeCookiePath(path) {
37017
+ if (path === void 0 || path === "")
37018
+ return void 0;
37019
+ const trimmed = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
37020
+ try {
37021
+ assertCookiePath(trimmed, "path");
37022
+ } catch (e) {
37023
+ throw new Error(`FetchproxyServer: ${e instanceof Error ? e.message : String(e)} (got ${JSON.stringify(path)})`);
37024
+ }
37025
+ return trimmed;
37026
+ }
36685
37027
  function assertSubdomainLabel(label) {
36686
37028
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
36687
37029
  throw new Error(`FetchproxyServer: subdomain must be a DNS label like "www" or "api" (or dot-separated like "auth.api"), got ${JSON.stringify(label)}`);
@@ -36746,6 +37088,9 @@ var FetchproxyServer = class {
36746
37088
  // them off from `pending` (fetch) and `pendingReadCookies` (legacy
36747
37089
  // string-shape) so the response routing in `onInner` stays linear.
36748
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();
36749
37094
  // 0.3.0+: capture-header awaiters resolve a single string.
36750
37095
  pendingCapture = /* @__PURE__ */ new Map();
36751
37096
  // capture_redirect awaiters resolve the captured redirect URL string.
@@ -36872,6 +37217,8 @@ var FetchproxyServer = class {
36872
37217
  keepAliveIntervalMs: opts.keepAliveIntervalMs ?? 2e4,
36873
37218
  keepAliveMaxIdleMs: opts.keepAliveMaxIdleMs ?? 5 * 60 * 1e3,
36874
37219
  identityDir: opts.identityDir,
37220
+ allowNewExtensionIdentity: opts.allowNewExtensionIdentity,
37221
+ requireExtensionIdentity: opts.requireExtensionIdentity,
36875
37222
  onPairCode: opts.onPairCode
36876
37223
  };
36877
37224
  }
@@ -36971,7 +37318,8 @@ var FetchproxyServer = class {
36971
37318
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36972
37319
  ownDomSelectors: this.opts.domSelectors,
36973
37320
  ownGraphqlOps: this.opts.graphqlOps,
36974
- onPairCode: this.opts.onPairCode
37321
+ onPairCode: this.opts.onPairCode,
37322
+ extensionTrust: this.extensionTrust()
36975
37323
  });
36976
37324
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
36977
37325
  this.hostHandle.onExtensionDisconnect(() => {
@@ -37000,7 +37348,9 @@ var FetchproxyServer = class {
37000
37348
  localStoragePointers: this.opts.localStoragePointers,
37001
37349
  sessionStoragePointers: this.opts.sessionStoragePointers,
37002
37350
  domSelectors: this.opts.domSelectors,
37003
- graphqlOps: this.opts.graphqlOps
37351
+ graphqlOps: this.opts.graphqlOps,
37352
+ extensionTrust: this.extensionTrust(),
37353
+ requireExtensionIdentity: this.opts.requireExtensionIdentity
37004
37354
  });
37005
37355
  this.peerHandle.onInner((inner) => this.onInner(inner));
37006
37356
  this.peerHandle.onRenegotiate(() => {
@@ -37139,6 +37489,23 @@ var FetchproxyServer = class {
37139
37489
  markActive() {
37140
37490
  this.noteActivityForKeepalive();
37141
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
+ }
37142
37509
  noteActivityForKeepalive() {
37143
37510
  const intervalMs = this.opts.keepAliveIntervalMs;
37144
37511
  if (intervalMs <= 0)
@@ -37202,6 +37569,7 @@ var FetchproxyServer = class {
37202
37569
  this.pending.delete(id);
37203
37570
  this.pendingReadCookies.delete(id);
37204
37571
  this.pendingStorage.delete(id);
37572
+ this.pendingWriteCookies.delete(id);
37205
37573
  this.pendingCapture.delete(id);
37206
37574
  this.pendingRedirect.delete(id);
37207
37575
  this.pendingDownload.delete(id);
@@ -37323,7 +37691,7 @@ var FetchproxyServer = class {
37323
37691
  port: this.opts.port
37324
37692
  });
37325
37693
  }
37326
- return new FetchproxyProtocolError(result.error);
37694
+ return protocolErrorFrom(result.error);
37327
37695
  }
37328
37696
  /**
37329
37697
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -37359,10 +37727,20 @@ var FetchproxyServer = class {
37359
37727
  }
37360
37728
  const url2 = isAbsolute ? path : `https://${host}${path}`;
37361
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
+ }
37362
37740
  const init = {
37363
37741
  url: url2,
37364
37742
  method,
37365
- tabUrl: `https://${host}/`,
37743
+ tabUrl,
37366
37744
  headers: opts.headers,
37367
37745
  body: opts.body
37368
37746
  };
@@ -37574,9 +37952,14 @@ var FetchproxyServer = class {
37574
37952
  let inner;
37575
37953
  if (opts.keys !== void 0) {
37576
37954
  this.assertScopeSubset(opts.keys, this.opts.cookieKeys, "cookieKeys");
37955
+ const cookiePath = normalizeCookiePath(opts.path);
37577
37956
  const initV3 = {
37957
+ // Origin stays BARE. The path travels as its own validated field —
37958
+ // `assertHttpsOriginOnly` deliberately refuses a path here so one
37959
+ // cannot be used to re-point the read past the domain gate.
37578
37960
  origin: `https://${host}`,
37579
- keys: [...opts.keys]
37961
+ keys: [...opts.keys],
37962
+ ...cookiePath !== void 0 ? { path: cookiePath } : {}
37580
37963
  };
37581
37964
  inner = { type: "request", id, op: "read_cookies", init: initV3 };
37582
37965
  } else {
@@ -37589,10 +37972,65 @@ var FetchproxyServer = class {
37589
37972
  await this.sendInnerFrame(inner);
37590
37973
  const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37591
37974
  if (!result.ok) {
37592
- throw new FetchproxyProtocolError(result.error);
37975
+ throw protocolErrorFrom(result.error);
37593
37976
  }
37594
37977
  return result.cookies;
37595
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
+ }
37596
38034
  /**
37597
38035
  * 0.3.0+: read declared localStorage keys from the user's signed-in
37598
38036
  * tab. Requires `'read_local_storage'` in capabilities AND each key
@@ -38139,7 +38577,7 @@ var FetchproxyServer = class {
38139
38577
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
38140
38578
  }
38141
38579
  } else {
38142
- storageCb.reject(new FetchproxyProtocolError(inner.error));
38580
+ storageCb.reject(protocolErrorFrom(inner.error));
38143
38581
  }
38144
38582
  return;
38145
38583
  }
@@ -38153,7 +38591,7 @@ var FetchproxyServer = class {
38153
38591
  captureCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture awaiter`));
38154
38592
  }
38155
38593
  } else {
38156
- captureCb.reject(new FetchproxyProtocolError(inner.error));
38594
+ captureCb.reject(protocolErrorFrom(inner.error));
38157
38595
  }
38158
38596
  return;
38159
38597
  }
@@ -38167,7 +38605,7 @@ var FetchproxyServer = class {
38167
38605
  redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
38168
38606
  }
38169
38607
  } else {
38170
- redirectCb.reject(new FetchproxyProtocolError(inner.error));
38608
+ redirectCb.reject(protocolErrorFrom(inner.error));
38171
38609
  }
38172
38610
  return;
38173
38611
  }
@@ -38181,7 +38619,7 @@ var FetchproxyServer = class {
38181
38619
  idbCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on read_indexed_db awaiter`));
38182
38620
  }
38183
38621
  } else {
38184
- idbCb.reject(new FetchproxyProtocolError(inner.error));
38622
+ idbCb.reject(protocolErrorFrom(inner.error));
38185
38623
  }
38186
38624
  return;
38187
38625
  }
@@ -38195,7 +38633,7 @@ var FetchproxyServer = class {
38195
38633
  downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
38196
38634
  }
38197
38635
  } else {
38198
- downloadCb.reject(new FetchproxyProtocolError(inner.error));
38636
+ downloadCb.reject(protocolErrorFrom(inner.error));
38199
38637
  }
38200
38638
  return;
38201
38639
  }
@@ -38209,7 +38647,17 @@ var FetchproxyServer = class {
38209
38647
  graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
38210
38648
  }
38211
38649
  } else {
38212
- graphqlCb.reject(new FetchproxyProtocolError(inner.error));
38650
+ graphqlCb.reject(protocolErrorFrom(inner.error));
38651
+ }
38652
+ return;
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));
38213
38661
  }
38214
38662
  return;
38215
38663
  }
@@ -38252,6 +38700,9 @@ var FetchproxyServer = class {
38252
38700
  for (const { reject } of this.pendingStorage.values())
38253
38701
  reject(err);
38254
38702
  this.pendingStorage.clear();
38703
+ for (const { reject } of this.pendingWriteCookies.values())
38704
+ reject(err);
38705
+ this.pendingWriteCookies.clear();
38255
38706
  for (const { reject } of this.pendingCapture.values())
38256
38707
  reject(err);
38257
38708
  this.pendingCapture.clear();
@@ -38402,7 +38853,9 @@ async function runOneLift(opts) {
38402
38853
  if (opts.declare.cookies.length > 0) {
38403
38854
  const joined = await server.readCookies({
38404
38855
  keys: opts.declare.cookies,
38405
- ...storageDomainOpts
38856
+ ...storageDomainOpts,
38857
+ // Cookie-only: the other buckets are origin-scoped and ignore path.
38858
+ ...opts.storagePath !== void 0 ? { path: opts.storagePath } : {}
38406
38859
  });
38407
38860
  for (const piece of joined.split("; ")) {
38408
38861
  if (!piece)
@@ -38532,7 +38985,7 @@ function loadAccount(env = process.env) {
38532
38985
  // package.json
38533
38986
  var package_default = {
38534
38987
  name: "infinitecampus-mcp",
38535
- version: "2.4.3",
38988
+ version: "2.4.5",
38536
38989
  mcpName: "io.github.chrischall/infinitecampus-mcp",
38537
38990
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
38538
38991
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38575,8 +39028,8 @@ var package_default = {
38575
39028
  },
38576
39029
  dependencies: {
38577
39030
  "@chrischall/mcp-utils": "^0.14.0",
38578
- "@fetchproxy/bootstrap": "^1.9.1",
38579
- "@fetchproxy/server": "^1.7.0",
39031
+ "@fetchproxy/bootstrap": "^2.0.0",
39032
+ "@fetchproxy/server": "^2.0.0",
38580
39033
  "@modelcontextprotocol/sdk": "^1.29.0",
38581
39034
  dotenv: "^17.4.2",
38582
39035
  "node-html-parser": "^9.0.0",
@@ -38622,6 +39075,18 @@ function lifterFor(host) {
38622
39075
  // <district>.infinitecampus.org, etc.). Declare just this host
38623
39076
  // — each district is its own root, no wildcard needed.
38624
39077
  domains: [host],
39078
+ // IC is a Tomcat app deployed at /campus, and Tomcat scopes JSESSIONID
39079
+ // to the servlet context by default:
39080
+ //
39081
+ // JSESSIONID=...; Path=/campus; Secure; HttpOnly; SameSite=Lax
39082
+ //
39083
+ // chrome.cookies.get matches that Path against the URL it is given, so a
39084
+ // read aimed at the origin root silently returns nothing — which is
39085
+ // indistinguishable from "the user is signed out", and is why this path
39086
+ // never worked on any tenant. Verified live: without this the lift found
39087
+ // no cookies while a fetch through the same tab returned authenticated
39088
+ // data; with it, JSESSIONID + XSRF-TOKEN both come back.
39089
+ storagePath: "/campus",
38625
39090
  declare: {
38626
39091
  // JSESSIONID is HttpOnly (chrome.cookies.get sees it; the
38627
39092
  // security gate is the declared key list). XSRF-TOKEN is
@@ -38672,7 +39137,7 @@ async function liftBrowserSession(account2) {
38672
39137
  }
38673
39138
 
38674
39139
  // src/client.ts
38675
- import { writeFile as writeFile2, stat } from "fs/promises";
39140
+ import { writeFile as writeFile3, stat } from "fs/promises";
38676
39141
  import { dirname } from "path";
38677
39142
 
38678
39143
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
@@ -39132,7 +39597,7 @@ var ICClient = class {
39132
39597
  });
39133
39598
  if (!res.ok) throw new Error(`IC download ${res.status} for ${path}`);
39134
39599
  const buf = new Uint8Array(await res.arrayBuffer());
39135
- await writeFile2(destinationPath, buf);
39600
+ await writeFile3(destinationPath, buf);
39136
39601
  return {
39137
39602
  path: destinationPath,
39138
39603
  bytes: buf.byteLength,
@@ -40140,7 +40605,7 @@ function registerFeaturesTools(server, client) {
40140
40605
 
40141
40606
  // src/index.ts
40142
40607
  await loadDotenvSafely({
40143
- path: join2(dirname2(fileURLToPath(import.meta.url)), "..", ".env"),
40608
+ path: join3(dirname2(fileURLToPath(import.meta.url)), "..", ".env"),
40144
40609
  override: false
40145
40610
  });
40146
40611
  var AI_NOTICE = "[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.";
@@ -40158,7 +40623,7 @@ try {
40158
40623
  }
40159
40624
  var COMMON = {
40160
40625
  name: "infinitecampus",
40161
- version: "2.4.3"
40626
+ version: "2.4.5"
40162
40627
  // x-release-please-version
40163
40628
  };
40164
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.3', // 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.3",
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.9.1",
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.3",
9
+ "version": "2.4.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.4.3",
14
+ "version": "2.4.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },