canvas-parent-mcp 1.2.3 → 1.2.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 Canvas LMS (Instructure) — courses, grades, assignments, announcements, planner, conversations, files",
10
- "version": "1.2.3"
10
+ "version": "1.2.5"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Canvas LMS",
16
16
  "source": "./",
17
17
  "description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
18
- "version": "1.2.3",
18
+ "version": "1.2.5",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
3
  "displayName": "Canvas LMS",
4
- "version": "1.2.3",
4
+ "version": "1.2.5",
5
5
  "description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/auth.js CHANGED
@@ -144,23 +144,55 @@ export async function resolveAuth() {
144
144
  '(unset CANVAS_DISABLE_FETCHPROXY if it is set).');
145
145
  }
146
146
  /**
147
- * One lifter per declared domain, built lazily and reused.
147
+ * One lifter per resolved tenant HOST, built lazily and reused.
148
+ *
149
+ * Keyed on the host, not the declared domain: every *.instructure.com tenant
150
+ * declares the same wildcard but holds its own host-only cookies, so a
151
+ * domain-keyed map would hand the second tenant the first one's lifter.
148
152
  *
149
153
  * The scope is not static — self-hosted Canvas declares its literal hostname
150
- * while *.instructure.com tenants share one wildcard — so the lifter cannot be
151
- * a module-level constant. Caching is load-bearing rather than an
152
- * optimization: `createSessionLifter` single-flights concurrent calls, and
153
- * that only helps if renewals share ONE lifter instead of constructing a fresh
154
- * one per call.
154
+ * while hosted tenants share the wildcard — so the lifter cannot be a
155
+ * module-level constant. Caching is load-bearing rather than an optimization:
156
+ * `createSessionLifter` single-flights concurrent calls, and that only helps
157
+ * if renewals share ONE lifter instead of constructing a fresh one per call.
158
+ */
159
+ const liftersByHost = new Map();
160
+ /**
161
+ * Split a tenant host into the declared domain and the subdomain that actually
162
+ * holds the cookies.
163
+ *
164
+ * These are NOT the same thing, and conflating them is why the lift silently
165
+ * read nothing. Canvas declares the `instructure.com` wildcard so any district
166
+ * matches without a re-pair, but `canvas_session` / `pseudonym_credentials`
167
+ * are host-only on the tenant (`cms.instructure.com`). The extension resolves
168
+ * the storage origin from domain + subdomain, so declaring the wildcard alone
169
+ * pointed the read at `https://instructure.com` — an apex that holds nothing —
170
+ * and every lift reported the user signed out while they were signed in.
171
+ *
172
+ * Returns undefined for a self-hosted install, where the host IS the domain.
155
173
  */
156
- const liftersByDomain = new Map();
157
- function lifterFor(declaredDomain) {
158
- let lift = liftersByDomain.get(declaredDomain);
174
+ function storageSubdomainFor(declaredDomain, baseHost) {
175
+ // Only two cases exist, because `declaredDomain` is derived FROM `baseHost`
176
+ // a few lines up: either it collapsed to the instructure.com wildcard (so
177
+ // baseHost ends with `.instructure.com`), or it is baseHost itself. A
178
+ // `endsWith` guard here would be a third branch that cannot be reached, and
179
+ // an untestable branch is worse than an asserted invariant.
180
+ if (baseHost === declaredDomain)
181
+ return undefined;
182
+ return baseHost.slice(0, -(declaredDomain.length + 1));
183
+ }
184
+ function lifterFor(declaredDomain, baseHost) {
185
+ // Key on the resolved host: two tenants share one declared domain but hold
186
+ // different cookies, so caching by domain alone would hand the second
187
+ // tenant the first one's lifter.
188
+ const subdomain = storageSubdomainFor(declaredDomain, baseHost);
189
+ let lift = liftersByHost.get(baseHost);
159
190
  if (!lift) {
160
191
  lift = createSessionLifter({
161
192
  serverName: pkg.name,
162
193
  version: pkg.version,
163
194
  domains: [declaredDomain],
195
+ ...(subdomain !== undefined ? { storageSubdomain: subdomain } : {}),
164
196
  declare: {
165
197
  cookies: ['canvas_session', 'pseudonym_credentials'],
166
198
  localStorage: [],
@@ -168,7 +200,7 @@ function lifterFor(declaredDomain) {
168
200
  captureHeaders: [],
169
201
  },
170
202
  });
171
- liftersByDomain.set(declaredDomain, lift);
203
+ liftersByHost.set(baseHost, lift);
172
204
  }
173
205
  return lift;
174
206
  }
@@ -180,7 +212,7 @@ function lifterFor(declaredDomain) {
180
212
  */
181
213
  async function liftBrowserCookie(declaredDomain, baseHost) {
182
214
  try {
183
- const session = await lifterFor(declaredDomain)();
215
+ const session = await lifterFor(declaredDomain, baseHost)();
184
216
  const canvasSession = session.cookies['canvas_session'];
185
217
  const pseudoCreds = session.cookies['pseudonym_credentials'];
186
218
  if (!canvasSession || !pseudoCreds) {
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 resolved2 = 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 resolved2 = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
3655
3660
  schemelessOptions.skipEscape = true;
3656
3661
  return serialize(resolved2, 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
@@ -34998,8 +35018,15 @@ function createOAuth2Refresher(opts) {
34998
35018
  }
34999
35019
 
35000
35020
  // node_modules/@fetchproxy/protocol/dist/frames.js
35001
- var PROTOCOL_VERSION = 2;
35021
+ var PROTOCOL_VERSION = 3;
35002
35022
  var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
35023
+ function readySignaturePayload(mcpHelloNonce, extHelloNonce, extensionSessionPub) {
35024
+ const out = new Uint8Array(mcpHelloNonce.length + extHelloNonce.length + extensionSessionPub.length);
35025
+ out.set(mcpHelloNonce, 0);
35026
+ out.set(extHelloNonce, mcpHelloNonce.length);
35027
+ out.set(extensionSessionPub, mcpHelloNonce.length + extHelloNonce.length);
35028
+ return out;
35029
+ }
35003
35030
  var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
35004
35031
  "fetch",
35005
35032
  "read_cookies",
@@ -35010,7 +35037,8 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
35010
35037
  "read_indexed_db",
35011
35038
  "read_dom",
35012
35039
  "download",
35013
- "graphql"
35040
+ "graphql",
35041
+ "write_cookies"
35014
35042
  ]);
35015
35043
 
35016
35044
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -35121,6 +35149,15 @@ function assertHttpUrl(x, label) {
35121
35149
  throw new ProtocolError(`${label}: must be http(s), got ${u.protocol}`);
35122
35150
  }
35123
35151
  }
35152
+ function assertCookiePath(x, label) {
35153
+ assertString(x, label);
35154
+ if (!x.startsWith("/") || x.startsWith("//")) {
35155
+ throw new ProtocolError(`${label}: must be an absolute path like "/campus"`);
35156
+ }
35157
+ if (x.includes("?") || x.includes("#") || x.includes("\\")) {
35158
+ throw new ProtocolError(`${label}: must not contain a query, fragment, or backslash`);
35159
+ }
35160
+ }
35124
35161
  function assertHttpsOriginOnly(x, label) {
35125
35162
  assertString(x, label);
35126
35163
  let u;
@@ -35565,13 +35602,43 @@ function validateInnerRequest(raw) {
35565
35602
  }
35566
35603
  assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35567
35604
  assertNonEmptyKeyArray(raw.init.keys, "inner.init.keys");
35605
+ if (raw.init.path !== void 0)
35606
+ assertCookiePath(raw.init.path, "inner.init.path");
35568
35607
  for (const k of Object.keys(raw.init)) {
35569
- if (k !== "origin" && k !== "keys") {
35608
+ if (k !== "origin" && k !== "keys" && k !== "path") {
35570
35609
  throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_cookies`);
35571
35610
  }
35572
35611
  }
35573
35612
  return raw;
35574
35613
  }
35614
+ if (raw.op === "write_cookies") {
35615
+ assertObject(raw.init, "inner.init");
35616
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35617
+ if (!Array.isArray(raw.init.cookies) || raw.init.cookies.length === 0) {
35618
+ throw new ProtocolError("inner.init.cookies: must be a non-empty array");
35619
+ }
35620
+ for (const [i, entry] of raw.init.cookies.entries()) {
35621
+ assertObject(entry, `inner.init.cookies[${i}]`);
35622
+ assertString(entry.name, `inner.init.cookies[${i}].name`);
35623
+ if (!SCOPE_KEY_RE.test(entry.name)) {
35624
+ throw new ProtocolError(`inner.init.cookies[${i}].name: invalid key ${JSON.stringify(entry.name)}`);
35625
+ }
35626
+ assertString(entry.value, `inner.init.cookies[${i}].value`);
35627
+ for (const k of Object.keys(entry)) {
35628
+ if (k !== "name" && k !== "value") {
35629
+ throw new ProtocolError(`inner.init.cookies[${i}]: unexpected field ${JSON.stringify(k)}`);
35630
+ }
35631
+ }
35632
+ }
35633
+ if (raw.init.path !== void 0)
35634
+ assertCookiePath(raw.init.path, "inner.init.path");
35635
+ for (const k of Object.keys(raw.init)) {
35636
+ if (k !== "origin" && k !== "cookies" && k !== "path") {
35637
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on write_cookies`);
35638
+ }
35639
+ }
35640
+ return raw;
35641
+ }
35575
35642
  if (raw.op === "read_local_storage" || raw.op === "read_session_storage") {
35576
35643
  assertObject(raw.init, "inner.init");
35577
35644
  if (raw.init.origin === void 0) {
@@ -35758,7 +35825,7 @@ function validateInnerRequest(raw) {
35758
35825
  }
35759
35826
  return raw;
35760
35827
  }
35761
- 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)}`);
35828
+ 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)}`);
35762
35829
  }
35763
35830
  function assertNonEmptyKeyArray(value, label) {
35764
35831
  if (!Array.isArray(value)) {
@@ -35819,6 +35886,18 @@ function validateInnerResponse(raw) {
35819
35886
  }
35820
35887
  return raw;
35821
35888
  }
35889
+ if (op === "write_cookies") {
35890
+ if (raw.written === void 0) {
35891
+ throw new ProtocolError("inner.written: missing on write_cookies response");
35892
+ }
35893
+ if (!Array.isArray(raw.written)) {
35894
+ throw new ProtocolError("inner.written: must be an array");
35895
+ }
35896
+ for (const [i, name] of raw.written.entries()) {
35897
+ assertString(name, `inner.written[${i}]`);
35898
+ }
35899
+ return raw;
35900
+ }
35822
35901
  if (op === "read_local_storage" || op === "read_session_storage") {
35823
35902
  if (raw.values === void 0) {
35824
35903
  throw new ProtocolError(`inner.values: missing on ${String(op)} response`);
@@ -36296,6 +36375,148 @@ async function awaitSessionReady(ready, opts) {
36296
36375
  }
36297
36376
  }
36298
36377
 
36378
+ // node_modules/@fetchproxy/server/dist/extension-trust.js
36379
+ import { readFile as readFile2, writeFile as writeFile2, rename, unlink, mkdir as mkdir2, chmod as chmod2 } from "node:fs/promises";
36380
+ import { join as join2 } from "node:path";
36381
+
36382
+ // node_modules/@fetchproxy/server/dist/identity.js
36383
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
36384
+ import { join } from "node:path";
36385
+ import { homedir } from "node:os";
36386
+ var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
36387
+ var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
36388
+ function defaultIdentityDir() {
36389
+ return join(homedir(), ".fetchproxy", "identity");
36390
+ }
36391
+ function safeIdentityFileBase(serverName) {
36392
+ if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
36393
+ throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
36394
+ }
36395
+ return serverName.replace(/\//g, "_");
36396
+ }
36397
+ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36398
+ const safeFile = safeIdentityFileBase(serverName);
36399
+ const path = join(dir, `${safeFile}.json`);
36400
+ await mkdir(dir, { recursive: true, mode: 448 });
36401
+ try {
36402
+ const raw = await readFile(path, "utf8");
36403
+ const j2 = JSON.parse(raw);
36404
+ return {
36405
+ x25519Priv: fromB64(j2.x25519Priv),
36406
+ x25519Pub: fromB64(j2.x25519Pub),
36407
+ ed25519Priv: fromB64(j2.ed25519Priv),
36408
+ ed25519Pub: fromB64(j2.ed25519Pub),
36409
+ createdAt: j2.createdAt
36410
+ };
36411
+ } catch (e) {
36412
+ if (e.code !== "ENOENT")
36413
+ throw e;
36414
+ }
36415
+ const x = await generateX25519();
36416
+ const ed = await generateEd25519();
36417
+ const id = {
36418
+ x25519Priv: x.privateKey,
36419
+ x25519Pub: x.publicKey,
36420
+ ed25519Priv: ed.privateKey,
36421
+ ed25519Pub: ed.publicKey,
36422
+ createdAt: Date.now()
36423
+ };
36424
+ const j = {
36425
+ x25519Priv: toB64(id.x25519Priv),
36426
+ x25519Pub: toB64(id.x25519Pub),
36427
+ ed25519Priv: toB64(id.ed25519Priv),
36428
+ ed25519Pub: toB64(id.ed25519Pub),
36429
+ createdAt: id.createdAt
36430
+ };
36431
+ await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
36432
+ await chmod(path, 384);
36433
+ return id;
36434
+ }
36435
+
36436
+ // node_modules/@fetchproxy/server/dist/extension-trust.js
36437
+ function fileExtensionTrust(args) {
36438
+ return {
36439
+ allowNew: args.allowNew,
36440
+ location: extensionTrustPath(args.serverName, args.dir ?? defaultIdentityDir()),
36441
+ read: () => readExtensionPin(args.serverName, args.dir ?? defaultIdentityDir()),
36442
+ write: (pin) => writeExtensionPin(args.serverName, pin, args.dir ?? defaultIdentityDir())
36443
+ };
36444
+ }
36445
+ var TRUST_NEW_EXTENSION_ENV = "FETCHPROXY_TRUST_NEW_EXTENSION";
36446
+ function allowNewExtensionIdentity(explicit, env = process.env) {
36447
+ if (explicit !== void 0)
36448
+ return explicit;
36449
+ return env[TRUST_NEW_EXTENSION_ENV] === "1";
36450
+ }
36451
+ function decideExtensionTrust(args) {
36452
+ const { pin, hello, allowNew, serverName } = args;
36453
+ if (!pin)
36454
+ return { decision: "first-use" };
36455
+ if (pin.identityX25519Pub === hello.identityX25519Pub && pin.identityEd25519Pub === hello.identityEd25519Pub) {
36456
+ return { decision: "pinned" };
36457
+ }
36458
+ const trustPath = args.location ?? extensionTrustPathHint(serverName);
36459
+ if (allowNew) {
36460
+ return {
36461
+ decision: "replace",
36462
+ 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.`
36463
+ };
36464
+ }
36465
+ return {
36466
+ decision: "refused",
36467
+ 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.`
36468
+ };
36469
+ }
36470
+ function extensionTrustPath(serverName, dir = defaultIdentityDir()) {
36471
+ return join2(dir, `${safeIdentityFileBase(serverName)}.extension-trust.json`);
36472
+ }
36473
+ function extensionTrustPathHint(serverName) {
36474
+ try {
36475
+ return extensionTrustPath(serverName);
36476
+ } catch {
36477
+ return join2(defaultIdentityDir(), "<server-name>.extension-trust.json");
36478
+ }
36479
+ }
36480
+ function isPin(x) {
36481
+ if (!x || typeof x !== "object")
36482
+ return false;
36483
+ const r = x;
36484
+ return typeof r.identityX25519Pub === "string" && typeof r.identityEd25519Pub === "string" && typeof r.pinnedAt === "number";
36485
+ }
36486
+ async function readExtensionPin(serverName, dir = defaultIdentityDir()) {
36487
+ const path = extensionTrustPath(serverName, dir);
36488
+ let raw;
36489
+ try {
36490
+ raw = await readFile2(path, "utf8");
36491
+ } catch (e) {
36492
+ if (e.code === "ENOENT")
36493
+ return null;
36494
+ throw e;
36495
+ }
36496
+ let parsed;
36497
+ try {
36498
+ parsed = JSON.parse(raw);
36499
+ } catch {
36500
+ throw new Error(`unreadable extension pin at ${path} (not JSON) \u2014 delete it to re-pair`);
36501
+ }
36502
+ if (!isPin(parsed)) {
36503
+ throw new Error(`unreadable extension pin at ${path} (wrong shape) \u2014 delete it to re-pair`);
36504
+ }
36505
+ return {
36506
+ identityX25519Pub: parsed.identityX25519Pub,
36507
+ identityEd25519Pub: parsed.identityEd25519Pub,
36508
+ pinnedAt: parsed.pinnedAt
36509
+ };
36510
+ }
36511
+ async function writeExtensionPin(serverName, pin, dir = defaultIdentityDir()) {
36512
+ const path = extensionTrustPath(serverName, dir);
36513
+ await mkdir2(dir, { recursive: true, mode: 448 });
36514
+ const tmp = `${path}.tmp`;
36515
+ await writeFile2(tmp, JSON.stringify(pin, null, 2), { mode: 384 });
36516
+ await chmod2(tmp, 384);
36517
+ await rename(tmp, path);
36518
+ }
36519
+
36299
36520
  // node_modules/@fetchproxy/server/dist/host.js
36300
36521
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
36301
36522
  var enc2 = new TextEncoder();
@@ -36349,9 +36570,12 @@ async function startHost(opts) {
36349
36570
  }
36350
36571
  resetSessionPromise();
36351
36572
  let extensionHello = null;
36573
+ let extensionClaim = null;
36352
36574
  wss.on("connection", (ws) => {
36353
36575
  let identified = null;
36354
36576
  let peerMcpId = null;
36577
+ let closed = false;
36578
+ let pinOnReady = false;
36355
36579
  ws.on("message", async (data) => {
36356
36580
  try {
36357
36581
  let frame;
@@ -36363,10 +36587,43 @@ async function startHost(opts) {
36363
36587
  return;
36364
36588
  }
36365
36589
  if (frame.type === "hello" && frame.role === "extension") {
36366
- if (extensionWs) {
36590
+ if (extensionWs || extensionClaim) {
36367
36591
  ws.close(1008, "extension already connected");
36368
36592
  return;
36369
36593
  }
36594
+ extensionClaim = ws;
36595
+ let pin;
36596
+ try {
36597
+ pin = await opts.extensionTrust.read();
36598
+ } catch (e) {
36599
+ console.error(`[fetchproxy] ${String(e)}`);
36600
+ if (extensionClaim === ws)
36601
+ extensionClaim = null;
36602
+ ws.close(1008, "extension pin unreadable");
36603
+ return;
36604
+ }
36605
+ const outcome = decideExtensionTrust({
36606
+ pin,
36607
+ hello: frame,
36608
+ allowNew: opts.extensionTrust.allowNew,
36609
+ serverName: opts.ownServerName,
36610
+ location: opts.extensionTrust.location
36611
+ });
36612
+ if (outcome.decision === "refused") {
36613
+ console.warn(outcome.message);
36614
+ if (extensionClaim === ws)
36615
+ extensionClaim = null;
36616
+ ws.close(1008, "extension identity is not the pinned one");
36617
+ return;
36618
+ }
36619
+ if (outcome.decision === "replace")
36620
+ console.warn(outcome.message);
36621
+ if (closed || ws.readyState !== import_websocket.default.OPEN) {
36622
+ if (extensionClaim === ws)
36623
+ extensionClaim = null;
36624
+ return;
36625
+ }
36626
+ pinOnReady = outcome.decision !== "pinned";
36370
36627
  identified = "extension";
36371
36628
  extensionWs = ws;
36372
36629
  extensionHello = frame;
@@ -36378,6 +36635,8 @@ async function startHost(opts) {
36378
36635
  console.error("[fetchproxy] onPairCode threw:", e);
36379
36636
  }
36380
36637
  }
36638
+ for (const slot of peers.values())
36639
+ slot.ws.send(JSON.stringify(frame));
36381
36640
  ws.send(JSON.stringify(ownHello));
36382
36641
  for (const slot of peers.values()) {
36383
36642
  ws.send(JSON.stringify(slot.helloFrame));
@@ -36413,6 +36672,8 @@ async function startHost(opts) {
36413
36672
  peers.set(frame.mcpId, { ws, helloFrame: frame });
36414
36673
  if (extensionWs)
36415
36674
  extensionWs.send(JSON.stringify(frame));
36675
+ if (extensionHello)
36676
+ ws.send(JSON.stringify(extensionHello));
36416
36677
  return;
36417
36678
  }
36418
36679
  if (frame.type === "ready") {
@@ -36424,7 +36685,7 @@ async function startHost(opts) {
36424
36685
  }
36425
36686
  const extEdPub = fromB64(extensionHello.identityEd25519Pub);
36426
36687
  const extNonce = fromB64(extensionHello.sessionNonce);
36427
- const msg = concatBytes(ownSessionNonce, extNonce);
36688
+ const msg = readySignaturePayload(ownSessionNonce, extNonce, fromB64(frame.extensionSessionPub));
36428
36689
  const sig = fromB64(frame.sessionSig);
36429
36690
  let sigOk = false;
36430
36691
  try {
@@ -36437,6 +36698,18 @@ async function startHost(opts) {
36437
36698
  ws.close(1008, "extension session signature invalid");
36438
36699
  return;
36439
36700
  }
36701
+ if (pinOnReady) {
36702
+ pinOnReady = false;
36703
+ try {
36704
+ await opts.extensionTrust.write({
36705
+ identityX25519Pub: extensionHello.identityX25519Pub,
36706
+ identityEd25519Pub: extensionHello.identityEd25519Pub,
36707
+ pinnedAt: Date.now()
36708
+ });
36709
+ } catch (e) {
36710
+ console.error(`[fetchproxy] could not persist the extension pin: ${String(e)}`);
36711
+ }
36712
+ }
36440
36713
  const extPub = fromB64(frame.extensionSessionPub);
36441
36714
  const shared = await ecdhX25519(opts.ownIdentity.x25519Priv, extPub);
36442
36715
  const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode(HKDF_SESSION_INFO), 32);
@@ -36490,6 +36763,9 @@ async function startHost(opts) {
36490
36763
  }
36491
36764
  });
36492
36765
  ws.on("close", () => {
36766
+ closed = true;
36767
+ if (extensionClaim === ws)
36768
+ extensionClaim = null;
36493
36769
  if (identified === "extension" && extensionWs === ws) {
36494
36770
  extensionWs = null;
36495
36771
  extensionHello = null;
@@ -36580,11 +36856,84 @@ async function startPeer(opts) {
36580
36856
  resolveFirstReady = resolve;
36581
36857
  rejectFirstReady = reject;
36582
36858
  });
36859
+ let extensionHello = null;
36860
+ let warnedUnverifiable = false;
36861
+ let cachedPin = void 0;
36862
+ const authenticateExtension = async (sessionSig, extensionSessionPub) => {
36863
+ if (!extensionHello) {
36864
+ if (opts.requireExtensionIdentity) {
36865
+ 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.`);
36866
+ return false;
36867
+ }
36868
+ if (!warnedUnverifiable) {
36869
+ warnedUnverifiable = true;
36870
+ 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.`);
36871
+ }
36872
+ return true;
36873
+ }
36874
+ const payload = readySignaturePayload(sessionNonce, fromB64(extensionHello.sessionNonce), fromB64(extensionSessionPub));
36875
+ let sigOk = false;
36876
+ try {
36877
+ sigOk = await ed25519Verify(fromB64(extensionHello.identityEd25519Pub), payload, fromB64(sessionSig));
36878
+ } catch {
36879
+ sigOk = false;
36880
+ }
36881
+ if (!sigOk) {
36882
+ console.warn(`[fetchproxy] ${opts.serverName}: extension session signature invalid \u2014 refusing (the concentrator may be answering in the browser's place)`);
36883
+ return false;
36884
+ }
36885
+ if (cachedPin === void 0) {
36886
+ try {
36887
+ cachedPin = await opts.extensionTrust.read();
36888
+ } catch (e) {
36889
+ console.error(`[fetchproxy] ${String(e)}`);
36890
+ return false;
36891
+ }
36892
+ }
36893
+ const pin = cachedPin;
36894
+ const outcome = decideExtensionTrust({
36895
+ pin,
36896
+ hello: extensionHello,
36897
+ allowNew: opts.extensionTrust.allowNew,
36898
+ serverName: opts.serverName,
36899
+ location: opts.extensionTrust.location
36900
+ });
36901
+ if (outcome.decision === "refused") {
36902
+ console.warn(outcome.message);
36903
+ return false;
36904
+ }
36905
+ if (outcome.decision === "replace")
36906
+ console.warn(outcome.message);
36907
+ if (outcome.decision !== "pinned") {
36908
+ try {
36909
+ const written = {
36910
+ identityX25519Pub: extensionHello.identityX25519Pub,
36911
+ identityEd25519Pub: extensionHello.identityEd25519Pub,
36912
+ pinnedAt: Date.now()
36913
+ };
36914
+ await opts.extensionTrust.write(written);
36915
+ cachedPin = written;
36916
+ } catch (e) {
36917
+ console.error(`[fetchproxy] could not persist the extension pin: ${String(e)}`);
36918
+ }
36919
+ }
36920
+ return true;
36921
+ };
36583
36922
  const onMessage = async (data) => {
36584
36923
  try {
36585
36924
  const raw = JSON.parse(data.toString());
36586
36925
  const frame = validateFrame(raw);
36926
+ if (frame.type === "hello" && frame.role === "extension") {
36927
+ extensionHello = frame;
36928
+ return;
36929
+ }
36587
36930
  if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
36931
+ const authorised = await authenticateExtension(frame.sessionSig, frame.extensionSessionPub);
36932
+ if (!authorised) {
36933
+ ws.close(1008, "extension identity refused");
36934
+ rejectFirstReady(new Error("peer: extension identity refused"));
36935
+ return;
36936
+ }
36588
36937
  const extPub = fromB64(frame.extensionSessionPub);
36589
36938
  const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
36590
36939
  const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
@@ -36665,57 +37014,6 @@ async function startPeer(opts) {
36665
37014
  return handle;
36666
37015
  }
36667
37016
 
36668
- // node_modules/@fetchproxy/server/dist/identity.js
36669
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
36670
- import { join } from "node:path";
36671
- import { homedir } from "node:os";
36672
- var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
36673
- var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
36674
- function defaultIdentityDir() {
36675
- return join(homedir(), ".fetchproxy", "identity");
36676
- }
36677
- async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36678
- if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
36679
- throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
36680
- }
36681
- const safeFile = serverName.replace(/\//g, "_");
36682
- const path = join(dir, `${safeFile}.json`);
36683
- await mkdir(dir, { recursive: true, mode: 448 });
36684
- try {
36685
- const raw = await readFile(path, "utf8");
36686
- const j2 = JSON.parse(raw);
36687
- return {
36688
- x25519Priv: fromB64(j2.x25519Priv),
36689
- x25519Pub: fromB64(j2.x25519Pub),
36690
- ed25519Priv: fromB64(j2.ed25519Priv),
36691
- ed25519Pub: fromB64(j2.ed25519Pub),
36692
- createdAt: j2.createdAt
36693
- };
36694
- } catch (e) {
36695
- if (e.code !== "ENOENT")
36696
- throw e;
36697
- }
36698
- const x = await generateX25519();
36699
- const ed = await generateEd25519();
36700
- const id = {
36701
- x25519Priv: x.privateKey,
36702
- x25519Pub: x.publicKey,
36703
- ed25519Priv: ed.privateKey,
36704
- ed25519Pub: ed.publicKey,
36705
- createdAt: Date.now()
36706
- };
36707
- const j = {
36708
- x25519Priv: toB64(id.x25519Priv),
36709
- x25519Pub: toB64(id.x25519Pub),
36710
- ed25519Priv: toB64(id.ed25519Priv),
36711
- ed25519Pub: toB64(id.ed25519Pub),
36712
- createdAt: id.createdAt
36713
- };
36714
- await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
36715
- await chmod(path, 384);
36716
- return id;
36717
- }
36718
-
36719
37017
  // node_modules/@fetchproxy/server/dist/error-kind.js
36720
37018
  function classifyFetchError(error51) {
36721
37019
  if (/Could not establish connection/i.test(error51) || /Receiving end does not exist/i.test(error51)) {
@@ -36797,6 +37095,39 @@ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
36797
37095
  this.hint = hint;
36798
37096
  }
36799
37097
  };
37098
+ var FetchproxyHintedError = class extends FetchproxyProtocolError {
37099
+ /** The extension's raw rejection, unmodified. */
37100
+ originalError;
37101
+ /** What the user should actually do, in prose. */
37102
+ hint;
37103
+ constructor(originalError, hint) {
37104
+ super(`${originalError} \u2014 ${hint}`);
37105
+ this.name = "FetchproxyHintedError";
37106
+ this.originalError = originalError;
37107
+ this.hint = hint;
37108
+ }
37109
+ };
37110
+ var FetchproxyScopeError = class extends FetchproxyHintedError {
37111
+ constructor(originalError) {
37112
+ 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.");
37113
+ this.name = "FetchproxyScopeError";
37114
+ }
37115
+ };
37116
+ var FetchproxyNoTabError = class extends FetchproxyHintedError {
37117
+ constructor(originalError) {
37118
+ 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.");
37119
+ this.name = "FetchproxyNoTabError";
37120
+ }
37121
+ };
37122
+ var SCOPE_REJECTION = /not in declared/;
37123
+ var NO_TAB_REJECTION = /no tab matching (?!.*content script loaded)/;
37124
+ function protocolErrorFrom(error51) {
37125
+ if (SCOPE_REJECTION.test(error51))
37126
+ return new FetchproxyScopeError(error51);
37127
+ if (NO_TAB_REJECTION.test(error51))
37128
+ return new FetchproxyNoTabError(error51);
37129
+ return new FetchproxyProtocolError(error51);
37130
+ }
36800
37131
  var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36801
37132
  url;
36802
37133
  timeoutMs;
@@ -36826,6 +37157,17 @@ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36826
37157
  }
36827
37158
  };
36828
37159
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
37160
+ function normalizeCookiePath(path) {
37161
+ if (path === void 0 || path === "")
37162
+ return void 0;
37163
+ const trimmed = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
37164
+ try {
37165
+ assertCookiePath(trimmed, "path");
37166
+ } catch (e) {
37167
+ throw new Error(`FetchproxyServer: ${e instanceof Error ? e.message : String(e)} (got ${JSON.stringify(path)})`);
37168
+ }
37169
+ return trimmed;
37170
+ }
36829
37171
  function assertSubdomainLabel(label) {
36830
37172
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
36831
37173
  throw new Error(`FetchproxyServer: subdomain must be a DNS label like "www" or "api" (or dot-separated like "auth.api"), got ${JSON.stringify(label)}`);
@@ -36890,6 +37232,9 @@ var FetchproxyServer = class {
36890
37232
  // them off from `pending` (fetch) and `pendingReadCookies` (legacy
36891
37233
  // string-shape) so the response routing in `onInner` stays linear.
36892
37234
  pendingStorage = /* @__PURE__ */ new Map();
37235
+ // 1.12.0+: write-cookies awaiters resolve the list of names actually
37236
+ // written, so a caller can confirm rather than assume.
37237
+ pendingWriteCookies = /* @__PURE__ */ new Map();
36893
37238
  // 0.3.0+: capture-header awaiters resolve a single string.
36894
37239
  pendingCapture = /* @__PURE__ */ new Map();
36895
37240
  // capture_redirect awaiters resolve the captured redirect URL string.
@@ -37016,6 +37361,8 @@ var FetchproxyServer = class {
37016
37361
  keepAliveIntervalMs: opts.keepAliveIntervalMs ?? 2e4,
37017
37362
  keepAliveMaxIdleMs: opts.keepAliveMaxIdleMs ?? 5 * 60 * 1e3,
37018
37363
  identityDir: opts.identityDir,
37364
+ allowNewExtensionIdentity: opts.allowNewExtensionIdentity,
37365
+ requireExtensionIdentity: opts.requireExtensionIdentity,
37019
37366
  onPairCode: opts.onPairCode
37020
37367
  };
37021
37368
  }
@@ -37115,7 +37462,8 @@ var FetchproxyServer = class {
37115
37462
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
37116
37463
  ownDomSelectors: this.opts.domSelectors,
37117
37464
  ownGraphqlOps: this.opts.graphqlOps,
37118
- onPairCode: this.opts.onPairCode
37465
+ onPairCode: this.opts.onPairCode,
37466
+ extensionTrust: this.extensionTrust()
37119
37467
  });
37120
37468
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
37121
37469
  this.hostHandle.onExtensionDisconnect(() => {
@@ -37144,7 +37492,9 @@ var FetchproxyServer = class {
37144
37492
  localStoragePointers: this.opts.localStoragePointers,
37145
37493
  sessionStoragePointers: this.opts.sessionStoragePointers,
37146
37494
  domSelectors: this.opts.domSelectors,
37147
- graphqlOps: this.opts.graphqlOps
37495
+ graphqlOps: this.opts.graphqlOps,
37496
+ extensionTrust: this.extensionTrust(),
37497
+ requireExtensionIdentity: this.opts.requireExtensionIdentity
37148
37498
  });
37149
37499
  this.peerHandle.onInner((inner) => this.onInner(inner));
37150
37500
  this.peerHandle.onRenegotiate(() => {
@@ -37283,6 +37633,23 @@ var FetchproxyServer = class {
37283
37633
  markActive() {
37284
37634
  this.noteActivityForKeepalive();
37285
37635
  }
37636
+ /**
37637
+ * #208: this MCP's pin on the extension's identity, stored beside its own
37638
+ * identity key and so following `identityDir` wherever the caller put it.
37639
+ *
37640
+ * `allowNewExtensionIdentity` falls back to an environment variable when the
37641
+ * caller expressed no opinion, because the thirteen MCPs that construct this
37642
+ * class are separate packages: an operator whose extension re-install has
37643
+ * just locked all of them out needs one lever that does not require patching
37644
+ * every one of them.
37645
+ */
37646
+ extensionTrust() {
37647
+ return fileExtensionTrust({
37648
+ serverName: this.opts.serverName,
37649
+ dir: this.opts.identityDir,
37650
+ allowNew: allowNewExtensionIdentity(this.opts.allowNewExtensionIdentity)
37651
+ });
37652
+ }
37286
37653
  noteActivityForKeepalive() {
37287
37654
  const intervalMs = this.opts.keepAliveIntervalMs;
37288
37655
  if (intervalMs <= 0)
@@ -37346,6 +37713,7 @@ var FetchproxyServer = class {
37346
37713
  this.pending.delete(id);
37347
37714
  this.pendingReadCookies.delete(id);
37348
37715
  this.pendingStorage.delete(id);
37716
+ this.pendingWriteCookies.delete(id);
37349
37717
  this.pendingCapture.delete(id);
37350
37718
  this.pendingRedirect.delete(id);
37351
37719
  this.pendingDownload.delete(id);
@@ -37467,7 +37835,7 @@ var FetchproxyServer = class {
37467
37835
  port: this.opts.port
37468
37836
  });
37469
37837
  }
37470
- return new FetchproxyProtocolError(result.error);
37838
+ return protocolErrorFrom(result.error);
37471
37839
  }
37472
37840
  /**
37473
37841
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -37503,10 +37871,20 @@ var FetchproxyServer = class {
37503
37871
  }
37504
37872
  const url2 = isAbsolute ? path : `https://${host}${path}`;
37505
37873
  assertUrlInDomains("request url", url2, this.opts.domains);
37874
+ let tabUrl = `https://${host}/`;
37875
+ if (opts.viaTab !== void 0) {
37876
+ try {
37877
+ new URL(opts.viaTab);
37878
+ } catch {
37879
+ throw new Error(`FetchproxyServer.request: viaTab is not a valid URL: ${JSON.stringify(opts.viaTab)}`);
37880
+ }
37881
+ assertUrlInDomains("viaTab", opts.viaTab, this.opts.domains);
37882
+ tabUrl = opts.viaTab;
37883
+ }
37506
37884
  const init = {
37507
37885
  url: url2,
37508
37886
  method,
37509
- tabUrl: `https://${host}/`,
37887
+ tabUrl,
37510
37888
  headers: opts.headers,
37511
37889
  body: opts.body
37512
37890
  };
@@ -37718,9 +38096,14 @@ var FetchproxyServer = class {
37718
38096
  let inner;
37719
38097
  if (opts.keys !== void 0) {
37720
38098
  this.assertScopeSubset(opts.keys, this.opts.cookieKeys, "cookieKeys");
38099
+ const cookiePath = normalizeCookiePath(opts.path);
37721
38100
  const initV3 = {
38101
+ // Origin stays BARE. The path travels as its own validated field —
38102
+ // `assertHttpsOriginOnly` deliberately refuses a path here so one
38103
+ // cannot be used to re-point the read past the domain gate.
37722
38104
  origin: `https://${host}`,
37723
- keys: [...opts.keys]
38105
+ keys: [...opts.keys],
38106
+ ...cookiePath !== void 0 ? { path: cookiePath } : {}
37724
38107
  };
37725
38108
  inner = { type: "request", id, op: "read_cookies", init: initV3 };
37726
38109
  } else {
@@ -37733,10 +38116,65 @@ var FetchproxyServer = class {
37733
38116
  await this.sendInnerFrame(inner);
37734
38117
  const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37735
38118
  if (!result.ok) {
37736
- throw new FetchproxyProtocolError(result.error);
38119
+ throw protocolErrorFrom(result.error);
37737
38120
  }
37738
38121
  return result.cookies;
37739
38122
  }
38123
+ /**
38124
+ * 1.12.0+: overwrite the value of cookies this MCP already declares.
38125
+ *
38126
+ * The bridge's only write verb, and it exists for one failure class. Sites
38127
+ * that ROTATE a credential cookie hand back a new value on every refresh; if
38128
+ * the MCP refreshes and keeps the result to itself, the copy in the browser's
38129
+ * cookie jar is dead, and the user gets signed out of a tab they never
38130
+ * touched — usually reported to them as "inactivity". Writing the rotated
38131
+ * value back is the only thing that repairs it.
38132
+ *
38133
+ * Requires `'write_cookies'` in capabilities, which the user approves at pair
38134
+ * time as its own line. Every name must ALSO be in declared `cookieKeys`: a
38135
+ * write can never reach a cookie the MCP was not already trusted to read, so
38136
+ * granting it cannot widen which cookies are in play — only what may be done
38137
+ * to the ones already listed.
38138
+ *
38139
+ * The extension refuses the whole request unless every named cookie already
38140
+ * exists; this refreshes a value in place and deliberately cannot author new
38141
+ * cookies. Returns the names actually written.
38142
+ */
38143
+ async writeCookies(opts) {
38144
+ if (!this.opts.capabilities.includes("write_cookies")) {
38145
+ throw new Error('FetchproxyServer.writeCookies(): MCP did not declare "write_cookies" in capabilities \u2014 add it to FetchproxyServerOpts.capabilities to enable this verb');
38146
+ }
38147
+ const names = Object.keys(opts.cookies);
38148
+ if (names.length === 0) {
38149
+ throw new Error("FetchproxyServer.writeCookies(): no cookies given");
38150
+ }
38151
+ await this.ensureConnected();
38152
+ this.throwIfPendingPair();
38153
+ if (opts.subdomain !== void 0)
38154
+ assertSubdomainLabel(opts.subdomain);
38155
+ const baseDomain = this.resolveBaseDomain(opts.domain);
38156
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
38157
+ this.assertScopeSubset(names, this.opts.cookieKeys, "cookieKeys");
38158
+ const cookiePath = normalizeCookiePath(opts.path);
38159
+ const id = this.nextRequestId++;
38160
+ const inner = {
38161
+ type: "request",
38162
+ id,
38163
+ op: "write_cookies",
38164
+ init: {
38165
+ // Bare origin, same invariant as the read path: a path must never be
38166
+ // able to move the request past the domain gate.
38167
+ origin: `https://${host}`,
38168
+ cookies: Object.entries(opts.cookies).map(([name, value]) => ({ name, value })),
38169
+ ...cookiePath !== void 0 ? { path: cookiePath } : {}
38170
+ }
38171
+ };
38172
+ const pending = new Promise((resolve, reject) => {
38173
+ this.pendingWriteCookies.set(id, { resolve, reject });
38174
+ });
38175
+ await this.sendInnerFrame(inner);
38176
+ return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
38177
+ }
37740
38178
  /**
37741
38179
  * 0.3.0+: read declared localStorage keys from the user's signed-in
37742
38180
  * tab. Requires `'read_local_storage'` in capabilities AND each key
@@ -38283,7 +38721,7 @@ var FetchproxyServer = class {
38283
38721
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
38284
38722
  }
38285
38723
  } else {
38286
- storageCb.reject(new FetchproxyProtocolError(inner.error));
38724
+ storageCb.reject(protocolErrorFrom(inner.error));
38287
38725
  }
38288
38726
  return;
38289
38727
  }
@@ -38297,7 +38735,7 @@ var FetchproxyServer = class {
38297
38735
  captureCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture awaiter`));
38298
38736
  }
38299
38737
  } else {
38300
- captureCb.reject(new FetchproxyProtocolError(inner.error));
38738
+ captureCb.reject(protocolErrorFrom(inner.error));
38301
38739
  }
38302
38740
  return;
38303
38741
  }
@@ -38311,7 +38749,7 @@ var FetchproxyServer = class {
38311
38749
  redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
38312
38750
  }
38313
38751
  } else {
38314
- redirectCb.reject(new FetchproxyProtocolError(inner.error));
38752
+ redirectCb.reject(protocolErrorFrom(inner.error));
38315
38753
  }
38316
38754
  return;
38317
38755
  }
@@ -38325,7 +38763,7 @@ var FetchproxyServer = class {
38325
38763
  idbCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on read_indexed_db awaiter`));
38326
38764
  }
38327
38765
  } else {
38328
- idbCb.reject(new FetchproxyProtocolError(inner.error));
38766
+ idbCb.reject(protocolErrorFrom(inner.error));
38329
38767
  }
38330
38768
  return;
38331
38769
  }
@@ -38339,7 +38777,7 @@ var FetchproxyServer = class {
38339
38777
  downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
38340
38778
  }
38341
38779
  } else {
38342
- downloadCb.reject(new FetchproxyProtocolError(inner.error));
38780
+ downloadCb.reject(protocolErrorFrom(inner.error));
38343
38781
  }
38344
38782
  return;
38345
38783
  }
@@ -38353,7 +38791,17 @@ var FetchproxyServer = class {
38353
38791
  graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
38354
38792
  }
38355
38793
  } else {
38356
- graphqlCb.reject(new FetchproxyProtocolError(inner.error));
38794
+ graphqlCb.reject(protocolErrorFrom(inner.error));
38795
+ }
38796
+ return;
38797
+ }
38798
+ const writeCookiesCb = this.pendingWriteCookies.get(inner.id);
38799
+ if (writeCookiesCb) {
38800
+ this.pendingWriteCookies.delete(inner.id);
38801
+ if (inner.ok && inner.op === "write_cookies") {
38802
+ writeCookiesCb.resolve([...inner.written]);
38803
+ } else {
38804
+ writeCookiesCb.reject(protocolErrorFrom(inner.ok ? "write_cookies response had the wrong op" : inner.error));
38357
38805
  }
38358
38806
  return;
38359
38807
  }
@@ -38396,6 +38844,9 @@ var FetchproxyServer = class {
38396
38844
  for (const { reject } of this.pendingStorage.values())
38397
38845
  reject(err);
38398
38846
  this.pendingStorage.clear();
38847
+ for (const { reject } of this.pendingWriteCookies.values())
38848
+ reject(err);
38849
+ this.pendingWriteCookies.clear();
38399
38850
  for (const { reject } of this.pendingCapture.values())
38400
38851
  reject(err);
38401
38852
  this.pendingCapture.clear();
@@ -38546,7 +38997,9 @@ async function runOneLift(opts) {
38546
38997
  if (opts.declare.cookies.length > 0) {
38547
38998
  const joined = await server.readCookies({
38548
38999
  keys: opts.declare.cookies,
38549
- ...storageDomainOpts
39000
+ ...storageDomainOpts,
39001
+ // Cookie-only: the other buckets are origin-scoped and ignore path.
39002
+ ...opts.storagePath !== void 0 ? { path: opts.storagePath } : {}
38550
39003
  });
38551
39004
  for (const piece of joined.split("; ")) {
38552
39005
  if (!piece)
@@ -38720,7 +39173,7 @@ function loadAccount(env = process.env) {
38720
39173
  // package.json
38721
39174
  var package_default = {
38722
39175
  name: "canvas-parent-mcp",
38723
- version: "1.2.3",
39176
+ version: "1.2.5",
38724
39177
  mcpName: "io.github.chrischall/canvas-parent-mcp",
38725
39178
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
38726
39179
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38765,8 +39218,8 @@ var package_default = {
38765
39218
  },
38766
39219
  dependencies: {
38767
39220
  "@chrischall/mcp-utils": "^0.14.0",
38768
- "@fetchproxy/bootstrap": "^1.9.1",
38769
- "@fetchproxy/server": "^1.7.0",
39221
+ "@fetchproxy/bootstrap": "^2.0.0",
39222
+ "@fetchproxy/server": "^2.0.0",
38770
39223
  "@modelcontextprotocol/sdk": "^1.29.0",
38771
39224
  dotenv: "^17.4.2",
38772
39225
  zod: "^4.4.3"
@@ -38822,14 +39275,20 @@ async function resolveAuth() {
38822
39275
  "Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (OAuth), or install the fetchproxy extension and sign into your Canvas instance (unset CANVAS_DISABLE_FETCHPROXY if it is set)."
38823
39276
  );
38824
39277
  }
38825
- var liftersByDomain = /* @__PURE__ */ new Map();
38826
- function lifterFor(declaredDomain) {
38827
- let lift = liftersByDomain.get(declaredDomain);
39278
+ var liftersByHost = /* @__PURE__ */ new Map();
39279
+ function storageSubdomainFor(declaredDomain, baseHost) {
39280
+ if (baseHost === declaredDomain) return void 0;
39281
+ return baseHost.slice(0, -(declaredDomain.length + 1));
39282
+ }
39283
+ function lifterFor(declaredDomain, baseHost) {
39284
+ const subdomain = storageSubdomainFor(declaredDomain, baseHost);
39285
+ let lift = liftersByHost.get(baseHost);
38828
39286
  if (!lift) {
38829
39287
  lift = createSessionLifter({
38830
39288
  serverName: package_default.name,
38831
39289
  version: package_default.version,
38832
39290
  domains: [declaredDomain],
39291
+ ...subdomain !== void 0 ? { storageSubdomain: subdomain } : {},
38833
39292
  declare: {
38834
39293
  cookies: ["canvas_session", "pseudonym_credentials"],
38835
39294
  localStorage: [],
@@ -38837,13 +39296,13 @@ function lifterFor(declaredDomain) {
38837
39296
  captureHeaders: []
38838
39297
  }
38839
39298
  });
38840
- liftersByDomain.set(declaredDomain, lift);
39299
+ liftersByHost.set(baseHost, lift);
38841
39300
  }
38842
39301
  return lift;
38843
39302
  }
38844
39303
  async function liftBrowserCookie(declaredDomain, baseHost) {
38845
39304
  try {
38846
- const session = await lifterFor(declaredDomain)();
39305
+ const session = await lifterFor(declaredDomain, baseHost)();
38847
39306
  const canvasSession = session.cookies["canvas_session"];
38848
39307
  const pseudoCreds = session.cookies["pseudonym_credentials"];
38849
39308
  if (!canvasSession || !pseudoCreds) {
@@ -38867,7 +39326,7 @@ async function liftBrowserCookie(declaredDomain, baseHost) {
38867
39326
  }
38868
39327
 
38869
39328
  // src/client.ts
38870
- import { writeFile as writeFile2, stat } from "fs/promises";
39329
+ import { writeFile as writeFile3, stat } from "fs/promises";
38871
39330
  import { dirname } from "path";
38872
39331
 
38873
39332
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
@@ -39180,7 +39639,7 @@ var CanvasClient = class {
39180
39639
  if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
39181
39640
  if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
39182
39641
  const buf = new Uint8Array(await res.arrayBuffer());
39183
- await writeFile2(destinationPath, buf);
39642
+ await writeFile3(destinationPath, buf);
39184
39643
  return {
39185
39644
  path: destinationPath,
39186
39645
  bytes: buf.byteLength,
@@ -39704,7 +40163,7 @@ function registerFileTools(server, client) {
39704
40163
 
39705
40164
  // src/index.ts
39706
40165
  var __dirname = dirname2(fileURLToPath(import.meta.url));
39707
- await loadDotenvSafely({ path: join2(__dirname, "..", ".env"), override: false });
40166
+ await loadDotenvSafely({ path: join3(__dirname, "..", ".env"), override: false });
39708
40167
  var resolved = null;
39709
40168
  var configError = null;
39710
40169
  try {
@@ -39714,7 +40173,7 @@ try {
39714
40173
  }
39715
40174
  await runMcp({
39716
40175
  name: "canvas",
39717
- version: "1.2.3",
40176
+ version: "1.2.5",
39718
40177
  // x-release-please-version
39719
40178
  tools: [
39720
40179
  (server) => {
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ catch (e) {
46
46
  // host's install-time `tools/list` succeeds.
47
47
  await runMcp({
48
48
  name: 'canvas',
49
- version: '1.2.3', // x-release-please-version
49
+ version: '1.2.5', // x-release-please-version
50
50
  tools: [
51
51
  (server) => {
52
52
  if (resolved) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "mcpName": "io.github.chrischall/canvas-parent-mcp",
5
5
  "description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -45,8 +45,8 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@chrischall/mcp-utils": "^0.14.0",
48
- "@fetchproxy/bootstrap": "^1.9.1",
49
- "@fetchproxy/server": "^1.7.0",
48
+ "@fetchproxy/bootstrap": "^2.0.0",
49
+ "@fetchproxy/server": "^2.0.0",
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
51
51
  "dotenv": "^17.4.2",
52
52
  "zod": "^4.4.3"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/canvas-parent-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.2.3",
9
+ "version": "1.2.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.2.3",
14
+ "version": "1.2.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },