canvas-parent-mcp 1.1.6 → 1.1.8

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.1.6"
10
+ "version": "1.1.8"
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.1.6",
18
+ "version": "1.1.8",
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.1.6",
4
+ "version": "1.1.8",
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
@@ -14,17 +14,20 @@
14
14
  // most reliable mode but most schools have disabled token creation
15
15
  // for non-admins. Unchanged from pre-fetchproxy behavior.
16
16
  //
17
- // 2. OAuth refresh token (existing)
18
- // CANVAS_CLIENT_ID + CANVAS_CLIENT_SECRET + CANVAS_REFRESH_TOKEN →
19
- // `grant_type=refresh_token` against `/login/oauth2/token`, bootstrap
20
- // via the bundled QR-login helper. Unchanged.
21
- //
22
- // 3. Username/password session-scrape (existing)
17
+ // 2. Username/password session-scrape (existing)
23
18
  // CANVAS_USERNAME + CANVAS_PASSWORD → scrape `authenticity_token`
24
19
  // from `/login/canvas`, POST creds, capture `canvas_session` +
25
20
  // `pseudonym_credentials` cookies. Brittle (breaks on SSO/2FA and
26
21
  // every Canvas login-page restyling) but works for direct Canvas
27
- // accounts. Unchanged from pre-fetchproxy behavior.
22
+ // accounts. Takes precedence over OAuth (path 3) — see the
23
+ // precedence order in `loadAccount()` (config.ts), which returns the
24
+ // username/password account before checking the OAuth triple.
25
+ // Unchanged from pre-fetchproxy behavior.
26
+ //
27
+ // 3. OAuth refresh token (existing)
28
+ // CANVAS_CLIENT_ID + CANVAS_CLIENT_SECRET + CANVAS_REFRESH_TOKEN →
29
+ // `grant_type=refresh_token` against `/login/oauth2/token`, bootstrap
30
+ // via the bundled QR-login helper. Unchanged.
28
31
  //
29
32
  // 4. fetchproxy fallback (new)
30
33
  // When no env vars are set, lift the user's session out of their
package/dist/bundle.js CHANGED
@@ -13,7 +13,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
13
13
  throw Error('Dynamic require of "' + x + '" is not supported');
14
14
  });
15
15
  var __commonJS = (cb, mod) => function __require2() {
16
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
16
+ try {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ } catch (e) {
19
+ throw mod = 0, e;
20
+ }
17
21
  };
18
22
  var __export = (target, all) => {
19
23
  for (var name in all)
@@ -34687,6 +34691,20 @@ async function runMcp(opts) {
34687
34691
 
34688
34692
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34689
34693
  var DEFAULT_ERROR_MESSAGE_MAX = 500;
34694
+ var McpToolError = class extends Error {
34695
+ /** Actionable remediation text, when one applies. */
34696
+ hint;
34697
+ constructor(message, opts) {
34698
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
34699
+ this.name = "McpToolError";
34700
+ if (opts?.hint !== void 0)
34701
+ this.hint = opts.hint;
34702
+ Object.setPrototypeOf(this, new.target.prototype);
34703
+ }
34704
+ };
34705
+ function createHelpfulError(message, opts) {
34706
+ return new McpToolError(message, opts);
34707
+ }
34690
34708
  var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
34691
34709
  var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
34692
34710
  var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
@@ -34707,8 +34725,12 @@ var API_KEY_RE = new RegExp([
34707
34725
  // webhook signing secret (Stripe-style)
34708
34726
  ].map((p) => `\\b${p}`).join("|"), "g");
34709
34727
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
34728
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
34729
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34730
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34731
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34710
34732
  function redactSecrets(text) {
34711
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
34733
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34712
34734
  }
34713
34735
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34714
34736
  const str = text === null || text === void 0 ? "" : String(text);
@@ -34756,6 +34778,23 @@ async function loadDotenvSafely(opts = {}) {
34756
34778
  }
34757
34779
 
34758
34780
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
34781
+ function buildQueryString(params) {
34782
+ const parts = [];
34783
+ for (const [key, value] of Object.entries(params)) {
34784
+ if (value === void 0 || value === null || value === "")
34785
+ continue;
34786
+ if (Array.isArray(value)) {
34787
+ for (const item of value) {
34788
+ if (item === void 0 || item === null || item === "")
34789
+ continue;
34790
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
34791
+ }
34792
+ } else {
34793
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
34794
+ }
34795
+ }
34796
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
34797
+ }
34759
34798
  function parseLinkHeader(header) {
34760
34799
  const out = {};
34761
34800
  if (!header)
@@ -34767,6 +34806,90 @@ function parseLinkHeader(header) {
34767
34806
  }
34768
34807
  return out;
34769
34808
  }
34809
+ var MAX_AGE_RE = /(?:^|;)\s*Max-Age\s*=\s*(-?\d+)\s*(?:;|$)/i;
34810
+ var EXPIRES_RE = /(?:^|;)\s*Expires\s*=\s*([^;]+)/i;
34811
+ var EXPIRES_DELETION_YEAR = 2e3;
34812
+ function isDeletionCookie(entry) {
34813
+ const maxAge = MAX_AGE_RE.exec(entry);
34814
+ if (maxAge)
34815
+ return Number(maxAge[1]) <= 0;
34816
+ const expires = EXPIRES_RE.exec(entry);
34817
+ if (expires && expires[1]) {
34818
+ const when = new Date(expires[1].trim());
34819
+ if (!Number.isNaN(when.getTime()))
34820
+ return when.getUTCFullYear() < EXPIRES_DELETION_YEAR;
34821
+ }
34822
+ return false;
34823
+ }
34824
+ function parseNameValue(entry) {
34825
+ const nameValue = (entry.split(";")[0] ?? "").trim();
34826
+ const eqIdx = nameValue.indexOf("=");
34827
+ if (eqIdx < 1)
34828
+ return null;
34829
+ return { name: nameValue.slice(0, eqIdx).trim(), value: nameValue.slice(eqIdx + 1).trim() };
34830
+ }
34831
+ function parseCookieJar(setCookieHeaders) {
34832
+ const entries = setCookieEntries(setCookieHeaders);
34833
+ const jar = /* @__PURE__ */ new Map();
34834
+ for (const entry of entries) {
34835
+ if (isDeletionCookie(entry))
34836
+ continue;
34837
+ const pair = parseNameValue(entry);
34838
+ if (!pair || !pair.value)
34839
+ continue;
34840
+ jar.set(pair.name, pair.value);
34841
+ }
34842
+ const cookies = {};
34843
+ for (const [k, v] of jar)
34844
+ cookies[k] = v;
34845
+ const cookieHeader = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
34846
+ return { cookies, cookieHeader };
34847
+ }
34848
+ function setCookieEntries(source) {
34849
+ if (source == null)
34850
+ return [];
34851
+ if (Array.isArray(source))
34852
+ return source;
34853
+ if (typeof source === "string")
34854
+ return splitSetCookie(source);
34855
+ if (typeof source.getSetCookie === "function")
34856
+ return source.getSetCookie();
34857
+ const joined = source.get("set-cookie");
34858
+ return joined ? splitSetCookie(joined) : [];
34859
+ }
34860
+ var CookieJar = class {
34861
+ jar = /* @__PURE__ */ new Map();
34862
+ /** Merge `Set-Cookie`s into the jar (later wins; deletion markers remove). */
34863
+ absorb(setCookies) {
34864
+ for (const entry of setCookieEntries(setCookies)) {
34865
+ const pair = parseNameValue(entry);
34866
+ if (!pair)
34867
+ continue;
34868
+ if (isDeletionCookie(entry)) {
34869
+ this.jar.delete(pair.name);
34870
+ continue;
34871
+ }
34872
+ if (!pair.value)
34873
+ continue;
34874
+ this.jar.set(pair.name, pair.value);
34875
+ }
34876
+ }
34877
+ /** The current value of a cookie, or `undefined` when absent/deleted. */
34878
+ get(name) {
34879
+ return this.jar.get(name);
34880
+ }
34881
+ /** Render the `Cookie` request-header value: `name=value; name2=value2`. */
34882
+ header() {
34883
+ return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
34884
+ }
34885
+ /** Number of cookies currently in the jar. */
34886
+ get size() {
34887
+ return this.jar.size;
34888
+ }
34889
+ };
34890
+ function splitSetCookie(header) {
34891
+ return header.split(/,(?=\s*[^;,\s]+\s*=)/).map((s) => s.trim()).filter((s) => s.length > 0);
34892
+ }
34770
34893
 
34771
34894
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
34772
34895
  var PositiveInt = external_exports.number().int().positive();
@@ -34787,6 +34910,74 @@ var pageSchema = {
34787
34910
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34788
34911
  };
34789
34912
 
34913
+ // node_modules/@chrischall/mcp-utils/dist/auth/index.js
34914
+ var sleep = (ms) => ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
34915
+ function createOAuth2Refresher(opts) {
34916
+ const doFetch = opts.fetchImpl ?? fetch;
34917
+ const grantType = opts.grantType ?? "refresh_token";
34918
+ const maxRetries = opts.retry?.count ?? 0;
34919
+ const retryDelayMs = opts.retry?.delayMs ?? 0;
34920
+ let inFlight = null;
34921
+ async function exchangeOnce() {
34922
+ const body = new URLSearchParams({
34923
+ grant_type: grantType,
34924
+ refresh_token: opts.refreshToken,
34925
+ ...opts.params
34926
+ }).toString();
34927
+ const res = await doFetch(opts.endpoint, {
34928
+ method: "POST",
34929
+ headers: {
34930
+ "Content-Type": "application/x-www-form-urlencoded",
34931
+ Accept: "application/json"
34932
+ },
34933
+ body
34934
+ });
34935
+ if (!res.ok) {
34936
+ const errText = await res.text().catch(() => "");
34937
+ throw createHelpfulError(`OAuth2 token refresh failed: ${res.status} ${res.statusText}: ${truncateErrorMessage(errText, 200)}`, { hint: "The refresh token may be expired or revoked \u2014 re-authenticate." });
34938
+ }
34939
+ const data = await res.json().catch(() => null);
34940
+ const accessToken = data?.access_token;
34941
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
34942
+ throw createHelpfulError("OAuth2 token refresh returned no access_token.", {
34943
+ hint: "The token endpoint returned an unexpected body."
34944
+ });
34945
+ }
34946
+ const result = { accessToken };
34947
+ if (typeof data?.refresh_token === "string" && data.refresh_token.length > 0) {
34948
+ result.refreshToken = data.refresh_token;
34949
+ }
34950
+ if (typeof data?.expires_in === "number") {
34951
+ result.expiresIn = data.expires_in;
34952
+ result.expiresAt = new Date(Date.now() + data.expires_in * 1e3);
34953
+ }
34954
+ return result;
34955
+ }
34956
+ async function exchangeWithRetry() {
34957
+ let lastErr;
34958
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
34959
+ try {
34960
+ return await exchangeOnce();
34961
+ } catch (e) {
34962
+ lastErr = e;
34963
+ if (attempt < maxRetries)
34964
+ await sleep(retryDelayMs);
34965
+ }
34966
+ }
34967
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
34968
+ }
34969
+ return function refresh() {
34970
+ if (inFlight)
34971
+ return inFlight;
34972
+ const p = exchangeWithRetry().finally(() => {
34973
+ if (inFlight === p)
34974
+ inFlight = null;
34975
+ });
34976
+ inFlight = p;
34977
+ return p;
34978
+ };
34979
+ }
34980
+
34790
34981
  // node_modules/@fetchproxy/protocol/dist/frames.js
34791
34982
  var PROTOCOL_VERSION = 2;
34792
34983
  var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
@@ -35743,10 +35934,20 @@ async function openEncryptedFrame(sessionKey, frame) {
35743
35934
 
35744
35935
  // node_modules/@fetchproxy/server/dist/election.js
35745
35936
  import { createServer, Server as HttpServer } from "node:http";
35937
+ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
35746
35938
  async function electRole(opts) {
35747
35939
  const server = createServer();
35940
+ const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
35748
35941
  return new Promise((resolve, reject) => {
35942
+ let timer;
35943
+ const clearBindTimer = () => {
35944
+ if (timer) {
35945
+ clearTimeout(timer);
35946
+ timer = void 0;
35947
+ }
35948
+ };
35749
35949
  const onError = (e) => {
35950
+ clearBindTimer();
35750
35951
  server.removeListener("listening", onListening);
35751
35952
  if (e.code === "EADDRINUSE") {
35752
35953
  try {
@@ -35759,11 +35960,25 @@ async function electRole(opts) {
35759
35960
  }
35760
35961
  };
35761
35962
  const onListening = () => {
35963
+ clearBindTimer();
35762
35964
  server.removeListener("error", onError);
35763
35965
  resolve({ role: "host", server });
35764
35966
  };
35765
35967
  server.once("error", onError);
35766
35968
  server.once("listening", onListening);
35969
+ if (bindTimeoutMs > 0) {
35970
+ timer = setTimeout(() => {
35971
+ server.removeListener("error", onError);
35972
+ server.removeListener("listening", onListening);
35973
+ try {
35974
+ server.close();
35975
+ } catch {
35976
+ }
35977
+ reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
35978
+ }, bindTimeoutMs);
35979
+ if (typeof timer.unref === "function")
35980
+ timer.unref();
35981
+ }
35767
35982
  server.listen(opts.port, opts.host);
35768
35983
  });
35769
35984
  }
@@ -35856,6 +36071,44 @@ var SessionState = class {
35856
36071
  }
35857
36072
  };
35858
36073
 
36074
+ // node_modules/@fetchproxy/server/dist/session-ready.js
36075
+ var SESSION_READY_TIMEOUT_MS = 3e4;
36076
+ var FetchproxySessionNotReadyError = class extends Error {
36077
+ reason;
36078
+ pairCode;
36079
+ mcpId;
36080
+ hint;
36081
+ constructor(info) {
36082
+ const pairing = info.pairCode !== null && info.pairCode !== "";
36083
+ const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
36084
+ super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
36085
+ this.name = "FetchproxySessionNotReadyError";
36086
+ this.reason = pairing ? "pair-required" : "not-ready";
36087
+ this.pairCode = pairing ? info.pairCode : null;
36088
+ this.mcpId = info.mcpId;
36089
+ this.hint = hint;
36090
+ Object.setPrototypeOf(this, new.target.prototype);
36091
+ }
36092
+ };
36093
+ async function awaitSessionReady(ready, opts) {
36094
+ const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
36095
+ if (ms <= 0)
36096
+ return ready;
36097
+ let timer;
36098
+ const timeout = new Promise((_, reject) => {
36099
+ timer = setTimeout(() => {
36100
+ reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
36101
+ }, ms);
36102
+ timer.unref?.();
36103
+ });
36104
+ try {
36105
+ return await Promise.race([ready, timeout]);
36106
+ } finally {
36107
+ if (timer)
36108
+ clearTimeout(timer);
36109
+ }
36110
+ }
36111
+
35859
36112
  // node_modules/@fetchproxy/server/dist/host.js
35860
36113
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
35861
36114
  var enc2 = new TextEncoder();
@@ -35943,6 +36196,29 @@ async function startHost(opts) {
35943
36196
  return;
35944
36197
  }
35945
36198
  if (frame.type === "hello" && frame.role === "server") {
36199
+ const peerEdPub = fromB64(frame.identityEd25519Pub);
36200
+ const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
36201
+ const peerSig = fromB64(frame.sessionSig);
36202
+ let peerSigOk = false;
36203
+ try {
36204
+ peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
36205
+ } catch {
36206
+ peerSigOk = false;
36207
+ }
36208
+ if (!peerSigOk) {
36209
+ console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
36210
+ ws.close(1008, "peer hello signature invalid");
36211
+ return;
36212
+ }
36213
+ const existing = peers.get(frame.mcpId);
36214
+ if (existing && existing.ws !== ws) {
36215
+ const existingEdPub = existing.helloFrame.identityEd25519Pub;
36216
+ if (existingEdPub !== frame.identityEd25519Pub) {
36217
+ console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
36218
+ ws.close(1008, "mcpId already registered to another identity");
36219
+ return;
36220
+ }
36221
+ }
35946
36222
  identified = "peer";
35947
36223
  peerMcpId = frame.mcpId;
35948
36224
  peers.set(frame.mcpId, { ws, helloFrame: frame });
@@ -36035,8 +36311,10 @@ async function startHost(opts) {
36035
36311
  resetSessionPromise();
36036
36312
  disconnectListeners.forEach((cb) => cb());
36037
36313
  }
36038
- if (identified === "peer" && peerMcpId)
36039
- peers.delete(peerMcpId);
36314
+ if (identified === "peer" && peerMcpId) {
36315
+ if (peers.get(peerMcpId)?.ws === ws)
36316
+ peers.delete(peerMcpId);
36317
+ }
36040
36318
  });
36041
36319
  });
36042
36320
  return {
@@ -36052,7 +36330,10 @@ async function startHost(opts) {
36052
36330
  });
36053
36331
  }),
36054
36332
  sendOwnInner: async (inner) => {
36055
- const session = await ownSessionReady;
36333
+ const session = await awaitSessionReady(ownSessionReady, {
36334
+ mcpId: opts.ownMcpId,
36335
+ pendingPairCode: () => ownPendingPairCode
36336
+ });
36056
36337
  if (!extensionWs)
36057
36338
  throw new Error("host: no extension connected");
36058
36339
  const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
@@ -36157,7 +36438,10 @@ async function startPeer(opts) {
36157
36438
  ws,
36158
36439
  session: sessionPromise,
36159
36440
  sendInner: async (inner) => {
36160
- await sessionPromise;
36441
+ await awaitSessionReady(sessionPromise, {
36442
+ mcpId: opts.mcpId,
36443
+ pendingPairCode: () => pendingPairCode
36444
+ });
36161
36445
  const s = session;
36162
36446
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
36163
36447
  ws.send(JSON.stringify(sealed));
@@ -36823,6 +37107,35 @@ var FetchproxyServer = class {
36823
37107
  this.keepAliveTimer = null;
36824
37108
  }
36825
37109
  }
37110
+ /**
37111
+ * Send an inner request frame via whichever bridge handle is active. If the
37112
+ * send throws (e.g. `FetchproxySessionNotReadyError` — the session never
37113
+ * confirmed), the frame never reached the bridge, so no reply will arrive:
37114
+ * drop the just-registered pending resolver for this id (it lives in exactly
37115
+ * one of the op maps — request ids are unique) so it doesn't leak until the
37116
+ * server closes, then rethrow.
37117
+ */
37118
+ async sendInnerFrame(inner) {
37119
+ try {
37120
+ if (this.hostHandle) {
37121
+ await this.hostHandle.sendOwnInner(inner);
37122
+ } else if (this.peerHandle) {
37123
+ await this.peerHandle.sendInner(inner);
37124
+ }
37125
+ } catch (err) {
37126
+ if ("id" in inner && typeof inner.id === "number") {
37127
+ const { id } = inner;
37128
+ this.pending.delete(id);
37129
+ this.pendingReadCookies.delete(id);
37130
+ this.pendingStorage.delete(id);
37131
+ this.pendingCapture.delete(id);
37132
+ this.pendingRedirect.delete(id);
37133
+ this.pendingDownload.delete(id);
37134
+ this.pendingIdb.delete(id);
37135
+ }
37136
+ throw err;
37137
+ }
37138
+ }
36826
37139
  /**
36827
37140
  * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36828
37141
  * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
@@ -36834,11 +37147,7 @@ var FetchproxyServer = class {
36834
37147
  const pending = new Promise((resolve) => {
36835
37148
  this.pending.set(id, resolve);
36836
37149
  });
36837
- if (this.hostHandle) {
36838
- await this.hostHandle.sendOwnInner(inner);
36839
- } else if (this.peerHandle) {
36840
- await this.peerHandle.sendInner(inner);
36841
- }
37150
+ await this.sendInnerFrame(inner);
36842
37151
  const timeoutMs = this.opts.fetchTimeoutMs;
36843
37152
  if (timeoutMs === void 0 || timeoutMs <= 0)
36844
37153
  return pending;
@@ -36867,6 +37176,51 @@ var FetchproxyServer = class {
36867
37176
  clearTimeout(timer);
36868
37177
  }
36869
37178
  }
37179
+ /**
37180
+ * FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
37181
+ *
37182
+ * Before this, only `fetch()` raced its `pending` reply against a timer
37183
+ * (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
37184
+ * `capture_request_header`, `capture_redirect`, `download`, and
37185
+ * `read_indexed_db` awaited their `pending` promise with no race, so a
37186
+ * wedged extension hung the tool call indefinitely.
37187
+ *
37188
+ * `pending` is the already-registered reply promise. `pendingMap`/`id`
37189
+ * point at the op-specific map entry so we can drop it on expiry exactly
37190
+ * as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
37191
+ * resolve into a stale resolver / leak. On timeout we reject with a
37192
+ * `FetchproxyTimeoutError` (the same throwable the convenience methods
37193
+ * already surface for fetch timeouts). `0`/unset opts out (unbounded),
37194
+ * matching the fetch path.
37195
+ */
37196
+ async _withVerbTimeout(pending, pendingMap, id, url2) {
37197
+ const timeoutMs = this.opts.fetchTimeoutMs;
37198
+ if (timeoutMs === void 0 || timeoutMs <= 0)
37199
+ return pending;
37200
+ let timer;
37201
+ const start = Date.now();
37202
+ try {
37203
+ return await Promise.race([
37204
+ pending,
37205
+ new Promise((_resolve, reject) => {
37206
+ timer = setTimeout(() => {
37207
+ pendingMap.delete(id);
37208
+ reject(new FetchproxyTimeoutError({
37209
+ url: url2,
37210
+ timeoutMs,
37211
+ role: this.role,
37212
+ port: this.opts.port,
37213
+ elapsedMs: Date.now() - start,
37214
+ retryAttempted: false
37215
+ }));
37216
+ }, timeoutMs);
37217
+ })
37218
+ ]);
37219
+ } finally {
37220
+ if (timer)
37221
+ clearTimeout(timer);
37222
+ }
37223
+ }
36870
37224
  /**
36871
37225
  * Map an `ok:false` fetch result to its typed throwable. Centralizes
36872
37226
  * the kind-to-error-class switch so `request()` and (via the same
@@ -37157,12 +37511,8 @@ var FetchproxyServer = class {
37157
37511
  const pending = new Promise((resolve) => {
37158
37512
  this.pendingReadCookies.set(id, resolve);
37159
37513
  });
37160
- if (this.hostHandle) {
37161
- await this.hostHandle.sendOwnInner(inner);
37162
- } else if (this.peerHandle) {
37163
- await this.peerHandle.sendInner(inner);
37164
- }
37165
- const result = await pending;
37514
+ await this.sendInnerFrame(inner);
37515
+ const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37166
37516
  if (!result.ok) {
37167
37517
  throw new FetchproxyProtocolError(result.error);
37168
37518
  }
@@ -37228,12 +37578,8 @@ var FetchproxyServer = class {
37228
37578
  const pending = new Promise((resolve, reject) => {
37229
37579
  this.pendingStorage.set(id, { resolve, reject });
37230
37580
  });
37231
- if (this.hostHandle) {
37232
- await this.hostHandle.sendOwnInner(inner);
37233
- } else if (this.peerHandle) {
37234
- await this.peerHandle.sendInner(inner);
37235
- }
37236
- return pending;
37581
+ await this.sendInnerFrame(inner);
37582
+ return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
37237
37583
  }
37238
37584
  /**
37239
37585
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
@@ -37337,12 +37683,8 @@ var FetchproxyServer = class {
37337
37683
  const pending = new Promise((resolve, reject) => {
37338
37684
  this.pendingCapture.set(id, { resolve, reject });
37339
37685
  });
37340
- if (this.hostHandle) {
37341
- await this.hostHandle.sendOwnInner(inner);
37342
- } else if (this.peerHandle) {
37343
- await this.peerHandle.sendInner(inner);
37344
- }
37345
- return pending;
37686
+ await this.sendInnerFrame(inner);
37687
+ return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
37346
37688
  }
37347
37689
  /**
37348
37690
  * Snapshot the redirect target URL of the next request the browser
@@ -37426,12 +37768,8 @@ var FetchproxyServer = class {
37426
37768
  const pending = new Promise((resolve, reject) => {
37427
37769
  this.pendingRedirect.set(id, { resolve, reject });
37428
37770
  });
37429
- if (this.hostHandle) {
37430
- await this.hostHandle.sendOwnInner(inner);
37431
- } else if (this.peerHandle) {
37432
- await this.peerHandle.sendInner(inner);
37433
- }
37434
- return pending;
37771
+ await this.sendInnerFrame(inner);
37772
+ return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
37435
37773
  }
37436
37774
  /**
37437
37775
  * Download `url` through the BROWSER's own network stack via
@@ -37512,12 +37850,8 @@ var FetchproxyServer = class {
37512
37850
  const pending = new Promise((resolve, reject) => {
37513
37851
  this.pendingDownload.set(id, { resolve, reject });
37514
37852
  });
37515
- if (this.hostHandle) {
37516
- await this.hostHandle.sendOwnInner(inner);
37517
- } else if (this.peerHandle) {
37518
- await this.peerHandle.sendInner(inner);
37519
- }
37520
- return pending;
37853
+ await this.sendInnerFrame(inner);
37854
+ return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
37521
37855
  }
37522
37856
  /**
37523
37857
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
@@ -37564,12 +37898,8 @@ var FetchproxyServer = class {
37564
37898
  const pending = new Promise((resolve, reject) => {
37565
37899
  this.pendingIdb.set(id, { resolve, reject });
37566
37900
  });
37567
- if (this.hostHandle) {
37568
- await this.hostHandle.sendOwnInner(inner);
37569
- } else if (this.peerHandle) {
37570
- await this.peerHandle.sendInner(inner);
37571
- }
37572
- return pending;
37901
+ await this.sendInnerFrame(inner);
37902
+ return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37573
37903
  }
37574
37904
  assertScopeSubset(requested, declared, label) {
37575
37905
  const undeclared = undeclaredKeys(requested, declared);
@@ -38049,7 +38379,7 @@ function loadAccount(env = process.env) {
38049
38379
  // package.json
38050
38380
  var package_default = {
38051
38381
  name: "canvas-parent-mcp",
38052
- version: "1.1.6",
38382
+ version: "1.1.8",
38053
38383
  mcpName: "io.github.chrischall/canvas-parent-mcp",
38054
38384
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
38055
38385
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38093,7 +38423,7 @@ var package_default = {
38093
38423
  "test:watch": "vitest"
38094
38424
  },
38095
38425
  dependencies: {
38096
- "@chrischall/mcp-utils": "^0.8.0",
38426
+ "@chrischall/mcp-utils": "^0.12.0",
38097
38427
  "@fetchproxy/bootstrap": "^1.0.0",
38098
38428
  "@fetchproxy/server": "^1.0.0",
38099
38429
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38101,7 +38431,7 @@ var package_default = {
38101
38431
  zod: "^4.4.3"
38102
38432
  },
38103
38433
  devDependencies: {
38104
- "@types/node": "^25.9.1",
38434
+ "@types/node": "^26.0.0",
38105
38435
  "@vitest/coverage-v8": "^4.1.7",
38106
38436
  esbuild: "^0.28.0",
38107
38437
  typescript: "^6.0.3",
@@ -38195,18 +38525,30 @@ var CookieSessionManager = class {
38195
38525
  inFlight;
38196
38526
  /** A cached *permanent* config error: once set, every `ensure` rethrows it. */
38197
38527
  permanentError;
38528
+ /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
38529
+ sessionAt = 0;
38198
38530
  loginFn;
38199
38531
  isExpiredFn;
38200
38532
  isPermanentErrorFn;
38533
+ maxAgeMs;
38534
+ now;
38535
+ onReplayLoginErrorFn;
38201
38536
  constructor(opts) {
38202
38537
  this.loginFn = opts.login;
38203
- this.isExpiredFn = opts.isExpired;
38538
+ this.isExpiredFn = opts.isExpired ?? (() => false);
38204
38539
  this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
38540
+ this.maxAgeMs = opts.maxAgeMs;
38541
+ this.now = opts.now ?? Date.now;
38542
+ this.onReplayLoginErrorFn = opts.onReplayLoginError;
38205
38543
  }
38206
38544
  /** The current session, or `undefined` before the first successful login. */
38207
38545
  get current() {
38208
38546
  return this.session;
38209
38547
  }
38548
+ /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */
38549
+ isStale() {
38550
+ return this.maxAgeMs !== void 0 && this.now() - this.sessionAt >= this.maxAgeMs;
38551
+ }
38210
38552
  /**
38211
38553
  * Return the current session, or single-flight a login if there is none.
38212
38554
  *
@@ -38215,10 +38557,16 @@ var CookieSessionManager = class {
38215
38557
  * the next call retries. A login error classified permanent by
38216
38558
  * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
38217
38559
  * on every later call; a transient error is not cached (next call retries).
38560
+ * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its
38561
+ * TTL is invalidated first, so the call falls through to a (single-flight)
38562
+ * re-login.
38218
38563
  */
38219
38564
  async ensure() {
38220
- if (this.session !== void 0)
38221
- return this.session;
38565
+ if (this.session !== void 0) {
38566
+ if (!this.isStale())
38567
+ return this.session;
38568
+ this.invalidate();
38569
+ }
38222
38570
  if (this.permanentError !== void 0)
38223
38571
  throw this.permanentError;
38224
38572
  if (this.inFlight === void 0) {
@@ -38226,6 +38574,21 @@ var CookieSessionManager = class {
38226
38574
  }
38227
38575
  return this.inFlight;
38228
38576
  }
38577
+ /**
38578
+ * Install an externally-minted session (e.g. infinitecampus's CUPS linked-
38579
+ * district discovery, which mints sessions outside {@link
38580
+ * CookieSessionManagerOptions.login}). The seed becomes the current session
38581
+ * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any
38582
+ * in-flight login is DETACHED: its waiters still receive its result, but a
38583
+ * late resolution will not overwrite the seed. A cached permanent config
38584
+ * error is left intact — the login path is still misconfigured, and the next
38585
+ * post-seed re-login should keep saying so.
38586
+ */
38587
+ seed(session) {
38588
+ this.session = session;
38589
+ this.sessionAt = this.now();
38590
+ this.inFlight = void 0;
38591
+ }
38229
38592
  /**
38230
38593
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
38231
38594
  * never sticks, and stamps the resulting session only while it's still the
@@ -38237,8 +38600,10 @@ var CookieSessionManager = class {
38237
38600
  holder.p = (async () => {
38238
38601
  try {
38239
38602
  const session = await this.loginFn();
38240
- if (this.inFlight === holder.p)
38603
+ if (this.inFlight === holder.p) {
38241
38604
  this.session = session;
38605
+ this.sessionAt = this.now();
38606
+ }
38242
38607
  return session;
38243
38608
  } catch (err) {
38244
38609
  if (this.isPermanentErrorFn(err))
@@ -38270,6 +38635,10 @@ var CookieSessionManager = class {
38270
38635
  * rather than looping. If the re-login itself fails, the original
38271
38636
  * (expired-looking) response is returned so the caller can surface a clean
38272
38637
  * sign-in error rather than the login failure.
38638
+ *
38639
+ * `call` resolves to `R` (the generic response type, default {@link Response});
38640
+ * the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired}
38641
+ * and returns it untouched, so a custom transport type flows through cleanly.
38273
38642
  */
38274
38643
  async withSession(call) {
38275
38644
  const session = await this.ensure();
@@ -38281,7 +38650,8 @@ var CookieSessionManager = class {
38281
38650
  let fresh;
38282
38651
  try {
38283
38652
  fresh = await this.ensure();
38284
- } catch {
38653
+ } catch (err) {
38654
+ this.onReplayLoginErrorFn?.(err);
38285
38655
  return res;
38286
38656
  }
38287
38657
  return call(fresh);
@@ -38306,27 +38676,12 @@ function extractAuthenticityToken(html) {
38306
38676
  }
38307
38677
  return null;
38308
38678
  }
38309
- function parseSetCookie(setCookieValue) {
38310
- const semi = setCookieValue.indexOf(";");
38311
- const nameValue = semi === -1 ? setCookieValue : setCookieValue.slice(0, semi);
38312
- const eq = nameValue.indexOf("=");
38313
- if (eq === -1) return null;
38314
- const name = nameValue.slice(0, eq).trim();
38315
- const value = nameValue.slice(eq + 1).trim();
38316
- if (!name) return null;
38317
- return { name, value };
38318
- }
38319
- function serializeCookies(cookies) {
38320
- return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
38321
- }
38322
- function collectSetCookies(headers) {
38323
- const out = [];
38324
- for (const sc of headers.getSetCookie()) {
38325
- const parsed = parseSetCookie(sc);
38326
- if (parsed) out.push(parsed);
38327
- }
38328
- return out;
38329
- }
38679
+ var SESSION_COOKIE_NAMES = [
38680
+ "canvas_session",
38681
+ "pseudonym_credentials",
38682
+ "_csrf_token",
38683
+ "log_session_id"
38684
+ ];
38330
38685
  async function sessionLogin(opts) {
38331
38686
  const baseUrl = opts.baseUrl.replace(/\/$/, "");
38332
38687
  const loginUrl = `${baseUrl}/login/canvas`;
@@ -38342,7 +38697,8 @@ async function sessionLogin(opts) {
38342
38697
  );
38343
38698
  }
38344
38699
  const html = await getRes.text();
38345
- const initialCookies = collectSetCookies(getRes.headers);
38700
+ const jar = new CookieJar();
38701
+ jar.absorb(getRes.headers);
38346
38702
  const authToken = extractAuthenticityToken(html);
38347
38703
  if (!authToken) {
38348
38704
  throw new SessionLoginError(
@@ -38364,14 +38720,14 @@ async function sessionLogin(opts) {
38364
38720
  "Content-Type": "application/x-www-form-urlencoded",
38365
38721
  Accept: "text/html,application/xhtml+xml",
38366
38722
  "User-Agent": USER_AGENT,
38367
- Cookie: serializeCookies(initialCookies),
38723
+ Cookie: jar.header(),
38368
38724
  Origin: baseUrl,
38369
38725
  Referer: loginUrl
38370
38726
  },
38371
38727
  body: body.toString()
38372
38728
  });
38373
- const respCookies = collectSetCookies(postRes.headers);
38374
- const hasCreds = respCookies.some((c) => c.name === "pseudonym_credentials");
38729
+ const respJar = parseCookieJar(postRes.headers.getSetCookie());
38730
+ const hasCreds = "pseudonym_credentials" in respJar.cookies;
38375
38731
  if (!hasCreds) {
38376
38732
  const location = postRes.headers.get("location") ?? "";
38377
38733
  if (location && !location.startsWith(baseUrl) && !location.includes("sso.canvaslms.com")) {
@@ -38383,10 +38739,8 @@ async function sessionLogin(opts) {
38383
38739
  "Login response did not include a pseudonym_credentials cookie \u2014 incorrect username or password, or the account is locked."
38384
38740
  );
38385
38741
  }
38386
- const sessionCookies = respCookies.filter(
38387
- (c) => ["canvas_session", "pseudonym_credentials", "_csrf_token", "log_session_id"].includes(c.name)
38388
- );
38389
- return { cookie: serializeCookies(sessionCookies) };
38742
+ const cookie = Object.entries(respJar.cookies).filter(([name]) => SESSION_COOKIE_NAMES.includes(name)).map(([name, value]) => `${name}=${value}`).join("; ");
38743
+ return { cookie };
38390
38744
  }
38391
38745
 
38392
38746
  // src/client.ts
@@ -38397,6 +38751,8 @@ var CanvasClient = class {
38397
38751
  sessionLoginFn;
38398
38752
  preloadedCookie;
38399
38753
  auth;
38754
+ /** Lazily-built shared refresh_token exchanger (oauth mode only). */
38755
+ oauthRefresh = null;
38400
38756
  /**
38401
38757
  * `preloaded` is the fetchproxy escape hatch: when set, the client uses
38402
38758
  * the supplied cookie header as-if it had just successfully run
@@ -38547,29 +38903,32 @@ var CanvasClient = class {
38547
38903
  });
38548
38904
  return result.cookie;
38549
38905
  }
38906
+ /**
38907
+ * Exchange the refresh token for a fresh access token via the fleet-shared
38908
+ * `createOAuth2Refresher` (which owns the form-encoded POST, the single-
38909
+ * in-flight guard, and the redact-then-truncate sanitizing of upstream error
38910
+ * bodies — so if Canvas's /login/oauth2/token error echoes the client_secret
38911
+ * or refresh_token, it never reaches the client-facing error). Canvas-specific
38912
+ * bits stay here: the TokenExpiredError('oauth') wrapper and the 60s
38913
+ * proactive-expiry skew baked into `accessTokenExpiresAt`.
38914
+ */
38550
38915
  async refreshAccessToken(acct) {
38551
- const body = new URLSearchParams({
38552
- grant_type: "refresh_token",
38553
- client_id: acct.clientId,
38554
- client_secret: acct.clientSecret,
38555
- refresh_token: acct.refreshToken
38556
- }).toString();
38557
- const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
38558
- method: "POST",
38559
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
38560
- body
38916
+ this.oauthRefresh ??= createOAuth2Refresher({
38917
+ endpoint: `${acct.baseUrl}/login/oauth2/token`,
38918
+ refreshToken: acct.refreshToken,
38919
+ params: { client_id: acct.clientId, client_secret: acct.clientSecret }
38561
38920
  });
38562
- if (!res.ok) {
38563
- const errBody = await res.text();
38564
- throw new TokenExpiredError(
38565
- "oauth",
38566
- `${res.status} ${res.statusText}: ${truncateErrorMessage(errBody, 200)}`
38567
- );
38921
+ let accessToken;
38922
+ let expiresIn;
38923
+ try {
38924
+ const result = await this.oauthRefresh();
38925
+ accessToken = result.accessToken;
38926
+ expiresIn = result.expiresIn ?? 3600;
38927
+ } catch (e) {
38928
+ throw new TokenExpiredError("oauth", e.message);
38568
38929
  }
38569
- const data = await res.json();
38570
- const expiresIn = data.expires_in ?? 3600;
38571
38930
  return {
38572
- headers: { Authorization: `Bearer ${data.access_token}` },
38931
+ headers: { Authorization: `Bearer ${accessToken}` },
38573
38932
  accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1e3
38574
38933
  };
38575
38934
  }
@@ -38631,16 +38990,7 @@ var FileExistsError = class extends Error {
38631
38990
  // src/tools/_shared.ts
38632
38991
  var textContent = textResult;
38633
38992
  function buildPath(base, params = {}) {
38634
- const parts = [];
38635
- for (const [key, value] of Object.entries(params)) {
38636
- if (value === void 0 || value === null) continue;
38637
- if (Array.isArray(value)) {
38638
- for (const v of value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
38639
- } else {
38640
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
38641
- }
38642
- }
38643
- return parts.length ? `${base}?${parts.join("&")}` : base;
38993
+ return `${base}${buildQueryString(params)}`;
38644
38994
  }
38645
38995
  function userSegment(observeeId) {
38646
38996
  return observeeId ? `users/${encodeURIComponent(observeeId)}` : "users/self";
@@ -39005,7 +39355,7 @@ try {
39005
39355
  }
39006
39356
  await runMcp({
39007
39357
  name: "canvas",
39008
- version: "1.1.6",
39358
+ version: "1.1.8",
39009
39359
  // x-release-please-version
39010
39360
  tools: [
39011
39361
  (server) => {
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
2
  import { dirname } from 'path';
3
- import { parseLinkHeader, truncateErrorMessage } from '@chrischall/mcp-utils';
3
+ import { createOAuth2Refresher, parseLinkHeader } from '@chrischall/mcp-utils';
4
4
  import { CookieSessionManager } from '@chrischall/mcp-utils/session';
5
5
  import { sessionLogin as defaultSessionLogin } from './session-login.js';
6
6
  // Re-export the fleet-shared RFC 5988 Link parser so existing importers
@@ -15,6 +15,8 @@ export class CanvasClient {
15
15
  sessionLoginFn;
16
16
  preloadedCookie;
17
17
  auth;
18
+ /** Lazily-built shared refresh_token exchanger (oauth mode only). */
19
+ oauthRefresh = null;
18
20
  /**
19
21
  * `preloaded` is the fetchproxy escape hatch: when set, the client uses
20
22
  * the supplied cookie header as-if it had just successfully run
@@ -187,30 +189,35 @@ export class CanvasClient {
187
189
  });
188
190
  return result.cookie;
189
191
  }
192
+ /**
193
+ * Exchange the refresh token for a fresh access token via the fleet-shared
194
+ * `createOAuth2Refresher` (which owns the form-encoded POST, the single-
195
+ * in-flight guard, and the redact-then-truncate sanitizing of upstream error
196
+ * bodies — so if Canvas's /login/oauth2/token error echoes the client_secret
197
+ * or refresh_token, it never reaches the client-facing error). Canvas-specific
198
+ * bits stay here: the TokenExpiredError('oauth') wrapper and the 60s
199
+ * proactive-expiry skew baked into `accessTokenExpiresAt`.
200
+ */
190
201
  async refreshAccessToken(acct) {
191
- const body = new URLSearchParams({
192
- grant_type: 'refresh_token',
193
- client_id: acct.clientId,
194
- client_secret: acct.clientSecret,
195
- refresh_token: acct.refreshToken,
196
- }).toString();
197
- const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
198
- method: 'POST',
199
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
200
- body,
202
+ this.oauthRefresh ??= createOAuth2Refresher({
203
+ endpoint: `${acct.baseUrl}/login/oauth2/token`,
204
+ refreshToken: acct.refreshToken,
205
+ params: { client_id: acct.clientId, client_secret: acct.clientSecret },
201
206
  });
202
- if (!res.ok) {
203
- // Run the upstream body through the fleet-shared sanitizer: it redacts
204
- // `Bearer <token>` headers and JWTs FIRST, then truncates — so if Canvas's
205
- // /login/oauth2/token error echoes the client_secret or refresh_token, it
206
- // never reaches the client-facing error (audit HIGH finding).
207
- const errBody = await res.text();
208
- throw new TokenExpiredError('oauth', `${res.status} ${res.statusText}: ${truncateErrorMessage(errBody, 200)}`);
207
+ let accessToken;
208
+ let expiresIn;
209
+ try {
210
+ const result = await this.oauthRefresh();
211
+ accessToken = result.accessToken;
212
+ expiresIn = result.expiresIn ?? 3600;
213
+ }
214
+ catch (e) {
215
+ // The refresher always throws Error (McpToolError) with a pre-redacted,
216
+ // pre-truncated message — safe to embed as the TokenExpiredError detail.
217
+ throw new TokenExpiredError('oauth', e.message);
209
218
  }
210
- const data = await res.json();
211
- const expiresIn = data.expires_in ?? 3600;
212
219
  return {
213
- headers: { Authorization: `Bearer ${data.access_token}` },
220
+ headers: { Authorization: `Bearer ${accessToken}` },
214
221
  accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1000,
215
222
  };
216
223
  }
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.1.6', // x-release-please-version
49
+ version: '1.1.8', // x-release-please-version
50
50
  tools: [
51
51
  (server) => {
52
52
  if (resolved) {
@@ -10,6 +10,12 @@
10
10
  // place of an OAuth bearer token; on a 401 the manager re-invokes sessionLogin
11
11
  // to mint a fresh jar. pseudonym_credentials is the "remember me" cookie with a
12
12
  // meaningful expiry (~14 days); canvas_session piggybacks on it.
13
+ //
14
+ // Cookie parsing/serialization is the fleet-shared `CookieJar` /
15
+ // `parseCookieJar` from `@chrischall/mcp-utils` (same attribute-stripping
16
+ // `name=value; …` semantics the hand-rolled helpers here used to implement,
17
+ // plus deletion-marker handling).
18
+ import { CookieJar, parseCookieJar } from '@chrischall/mcp-utils';
13
19
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
14
20
  '(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36';
15
21
  export class SessionLoginError extends Error {
@@ -30,31 +36,13 @@ export function extractAuthenticityToken(html) {
30
36
  }
31
37
  return null;
32
38
  }
33
- export function parseSetCookie(setCookieValue) {
34
- const semi = setCookieValue.indexOf(';');
35
- const nameValue = semi === -1 ? setCookieValue : setCookieValue.slice(0, semi);
36
- const eq = nameValue.indexOf('=');
37
- if (eq === -1)
38
- return null;
39
- const name = nameValue.slice(0, eq).trim();
40
- const value = nameValue.slice(eq + 1).trim();
41
- if (!name)
42
- return null;
43
- return { name, value };
44
- }
45
- export function serializeCookies(cookies) {
46
- return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
47
- }
48
- function collectSetCookies(headers) {
49
- const out = [];
50
- // Node 18+: Headers.getSetCookie() returns each Set-Cookie as a separate string.
51
- for (const sc of headers.getSetCookie()) {
52
- const parsed = parseSetCookie(sc);
53
- if (parsed)
54
- out.push(parsed);
55
- }
56
- return out;
57
- }
39
+ /** The cookies that actually authenticate Canvas API calls. */
40
+ const SESSION_COOKIE_NAMES = [
41
+ 'canvas_session',
42
+ 'pseudonym_credentials',
43
+ '_csrf_token',
44
+ 'log_session_id',
45
+ ];
58
46
  export async function sessionLogin(opts) {
59
47
  const baseUrl = opts.baseUrl.replace(/\/$/, '');
60
48
  const loginUrl = `${baseUrl}/login/canvas`;
@@ -69,7 +57,8 @@ export async function sessionLogin(opts) {
69
57
  throw new SessionLoginError(`Login page fetch failed: ${getRes.status} ${getRes.statusText}`);
70
58
  }
71
59
  const html = await getRes.text();
72
- const initialCookies = collectSetCookies(getRes.headers);
60
+ const jar = new CookieJar();
61
+ jar.absorb(getRes.headers);
73
62
  const authToken = extractAuthenticityToken(html);
74
63
  if (!authToken) {
75
64
  throw new SessionLoginError("Could not find authenticity_token on the login page. " +
@@ -93,7 +82,7 @@ export async function sessionLogin(opts) {
93
82
  'Content-Type': 'application/x-www-form-urlencoded',
94
83
  Accept: 'text/html,application/xhtml+xml',
95
84
  'User-Agent': USER_AGENT,
96
- Cookie: serializeCookies(initialCookies),
85
+ Cookie: jar.header(),
97
86
  Origin: baseUrl,
98
87
  Referer: loginUrl,
99
88
  },
@@ -102,8 +91,8 @@ export async function sessionLogin(opts) {
102
91
  // 3. Verify success by looking for the pseudonym_credentials cookie. Canvas sets it
103
92
  // only when authentication actually succeeded; a failed login returns a fresh
104
93
  // csrf token but no credentials cookie.
105
- const respCookies = collectSetCookies(postRes.headers);
106
- const hasCreds = respCookies.some((c) => c.name === 'pseudonym_credentials');
94
+ const respJar = parseCookieJar(postRes.headers.getSetCookie());
95
+ const hasCreds = 'pseudonym_credentials' in respJar.cookies;
107
96
  if (!hasCreds) {
108
97
  const location = postRes.headers.get('location') ?? '';
109
98
  if (location && !location.startsWith(baseUrl) && !location.includes('sso.canvaslms.com')) {
@@ -114,7 +103,10 @@ export async function sessionLogin(opts) {
114
103
  'incorrect username or password, or the account is locked.');
115
104
  }
116
105
  // Filter to the cookies that actually authenticate API calls. Path/Secure/HttpOnly
117
- // attributes are stripped during parseSetCookie, so we just join name=value pairs.
118
- const sessionCookies = respCookies.filter((c) => ['canvas_session', 'pseudonym_credentials', '_csrf_token', 'log_session_id'].includes(c.name));
119
- return { cookie: serializeCookies(sessionCookies) };
106
+ // attributes are stripped by parseCookieJar, so we just join name=value pairs.
107
+ const cookie = Object.entries(respJar.cookies)
108
+ .filter(([name]) => SESSION_COOKIE_NAMES.includes(name))
109
+ .map(([name, value]) => `${name}=${value}`)
110
+ .join('; ');
111
+ return { cookie };
120
112
  }
@@ -1,4 +1,4 @@
1
- import { textResult } from '@chrischall/mcp-utils';
1
+ import { buildQueryString, textResult } from '@chrischall/mcp-utils';
2
2
  /**
3
3
  * Wrap a value as an MCP text content block — the standard tool return shape.
4
4
  * Backed by the fleet-shared `textResult` from `@chrischall/mcp-utils` (same
@@ -19,19 +19,7 @@ export function toArray(value) {
19
19
  return Array.isArray(value) ? value : [value];
20
20
  }
21
21
  export function buildPath(base, params = {}) {
22
- const parts = [];
23
- for (const [key, value] of Object.entries(params)) {
24
- if (value === undefined || value === null)
25
- continue;
26
- if (Array.isArray(value)) {
27
- for (const v of value)
28
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
29
- }
30
- else {
31
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
32
- }
33
- }
34
- return parts.length ? `${base}?${parts.join('&')}` : base;
22
+ return `${base}${buildQueryString(params)}`;
35
23
  }
36
24
  /** Compute the user path segment: 'users/self' or 'users/{id}'. */
37
25
  export function userSegment(observeeId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
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>",
@@ -44,7 +44,7 @@
44
44
  "test:watch": "vitest"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.8.0",
47
+ "@chrischall/mcp-utils": "^0.12.0",
48
48
  "@fetchproxy/bootstrap": "^1.0.0",
49
49
  "@fetchproxy/server": "^1.0.0",
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -52,7 +52,7 @@
52
52
  "zod": "^4.4.3"
53
53
  },
54
54
  "devDependencies": {
55
- "@types/node": "^25.9.1",
55
+ "@types/node": "^26.0.0",
56
56
  "@vitest/coverage-v8": "^4.1.7",
57
57
  "esbuild": "^0.28.0",
58
58
  "typescript": "^6.0.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.1.6",
9
+ "version": "1.1.8",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.1.6",
14
+ "version": "1.1.8",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },