canvas-parent-mcp 1.1.7 → 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.7"
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.7",
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.7",
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/bundle.js CHANGED
@@ -34691,6 +34691,20 @@ async function runMcp(opts) {
34691
34691
 
34692
34692
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34693
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
+ }
34694
34708
  var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
34695
34709
  var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
34696
34710
  var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
@@ -34711,8 +34725,12 @@ var API_KEY_RE = new RegExp([
34711
34725
  // webhook signing secret (Stripe-style)
34712
34726
  ].map((p) => `\\b${p}`).join("|"), "g");
34713
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");
34714
34732
  function redactSecrets(text) {
34715
- 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]");
34716
34734
  }
34717
34735
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34718
34736
  const str = text === null || text === void 0 ? "" : String(text);
@@ -34760,6 +34778,23 @@ async function loadDotenvSafely(opts = {}) {
34760
34778
  }
34761
34779
 
34762
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
+ }
34763
34798
  function parseLinkHeader(header) {
34764
34799
  const out = {};
34765
34800
  if (!header)
@@ -34771,6 +34806,90 @@ function parseLinkHeader(header) {
34771
34806
  }
34772
34807
  return out;
34773
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
+ }
34774
34893
 
34775
34894
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
34776
34895
  var PositiveInt = external_exports.number().int().positive();
@@ -34791,6 +34910,74 @@ var pageSchema = {
34791
34910
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34792
34911
  };
34793
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
+
34794
34981
  // node_modules/@fetchproxy/protocol/dist/frames.js
34795
34982
  var PROTOCOL_VERSION = 2;
34796
34983
  var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
@@ -38192,7 +38379,7 @@ function loadAccount(env = process.env) {
38192
38379
  // package.json
38193
38380
  var package_default = {
38194
38381
  name: "canvas-parent-mcp",
38195
- version: "1.1.7",
38382
+ version: "1.1.8",
38196
38383
  mcpName: "io.github.chrischall/canvas-parent-mcp",
38197
38384
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
38198
38385
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38236,7 +38423,7 @@ var package_default = {
38236
38423
  "test:watch": "vitest"
38237
38424
  },
38238
38425
  dependencies: {
38239
- "@chrischall/mcp-utils": "^0.10.3",
38426
+ "@chrischall/mcp-utils": "^0.12.0",
38240
38427
  "@fetchproxy/bootstrap": "^1.0.0",
38241
38428
  "@fetchproxy/server": "^1.0.0",
38242
38429
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38338,18 +38525,30 @@ var CookieSessionManager = class {
38338
38525
  inFlight;
38339
38526
  /** A cached *permanent* config error: once set, every `ensure` rethrows it. */
38340
38527
  permanentError;
38528
+ /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
38529
+ sessionAt = 0;
38341
38530
  loginFn;
38342
38531
  isExpiredFn;
38343
38532
  isPermanentErrorFn;
38533
+ maxAgeMs;
38534
+ now;
38535
+ onReplayLoginErrorFn;
38344
38536
  constructor(opts) {
38345
38537
  this.loginFn = opts.login;
38346
38538
  this.isExpiredFn = opts.isExpired ?? (() => false);
38347
38539
  this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
38540
+ this.maxAgeMs = opts.maxAgeMs;
38541
+ this.now = opts.now ?? Date.now;
38542
+ this.onReplayLoginErrorFn = opts.onReplayLoginError;
38348
38543
  }
38349
38544
  /** The current session, or `undefined` before the first successful login. */
38350
38545
  get current() {
38351
38546
  return this.session;
38352
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
+ }
38353
38552
  /**
38354
38553
  * Return the current session, or single-flight a login if there is none.
38355
38554
  *
@@ -38358,10 +38557,16 @@ var CookieSessionManager = class {
38358
38557
  * the next call retries. A login error classified permanent by
38359
38558
  * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
38360
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.
38361
38563
  */
38362
38564
  async ensure() {
38363
- if (this.session !== void 0)
38364
- return this.session;
38565
+ if (this.session !== void 0) {
38566
+ if (!this.isStale())
38567
+ return this.session;
38568
+ this.invalidate();
38569
+ }
38365
38570
  if (this.permanentError !== void 0)
38366
38571
  throw this.permanentError;
38367
38572
  if (this.inFlight === void 0) {
@@ -38369,6 +38574,21 @@ var CookieSessionManager = class {
38369
38574
  }
38370
38575
  return this.inFlight;
38371
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
+ }
38372
38592
  /**
38373
38593
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
38374
38594
  * never sticks, and stamps the resulting session only while it's still the
@@ -38380,8 +38600,10 @@ var CookieSessionManager = class {
38380
38600
  holder.p = (async () => {
38381
38601
  try {
38382
38602
  const session = await this.loginFn();
38383
- if (this.inFlight === holder.p)
38603
+ if (this.inFlight === holder.p) {
38384
38604
  this.session = session;
38605
+ this.sessionAt = this.now();
38606
+ }
38385
38607
  return session;
38386
38608
  } catch (err) {
38387
38609
  if (this.isPermanentErrorFn(err))
@@ -38428,7 +38650,8 @@ var CookieSessionManager = class {
38428
38650
  let fresh;
38429
38651
  try {
38430
38652
  fresh = await this.ensure();
38431
- } catch {
38653
+ } catch (err) {
38654
+ this.onReplayLoginErrorFn?.(err);
38432
38655
  return res;
38433
38656
  }
38434
38657
  return call(fresh);
@@ -38453,27 +38676,12 @@ function extractAuthenticityToken(html) {
38453
38676
  }
38454
38677
  return null;
38455
38678
  }
38456
- function parseSetCookie(setCookieValue) {
38457
- const semi = setCookieValue.indexOf(";");
38458
- const nameValue = semi === -1 ? setCookieValue : setCookieValue.slice(0, semi);
38459
- const eq = nameValue.indexOf("=");
38460
- if (eq === -1) return null;
38461
- const name = nameValue.slice(0, eq).trim();
38462
- const value = nameValue.slice(eq + 1).trim();
38463
- if (!name) return null;
38464
- return { name, value };
38465
- }
38466
- function serializeCookies(cookies) {
38467
- return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
38468
- }
38469
- function collectSetCookies(headers) {
38470
- const out = [];
38471
- for (const sc of headers.getSetCookie()) {
38472
- const parsed = parseSetCookie(sc);
38473
- if (parsed) out.push(parsed);
38474
- }
38475
- return out;
38476
- }
38679
+ var SESSION_COOKIE_NAMES = [
38680
+ "canvas_session",
38681
+ "pseudonym_credentials",
38682
+ "_csrf_token",
38683
+ "log_session_id"
38684
+ ];
38477
38685
  async function sessionLogin(opts) {
38478
38686
  const baseUrl = opts.baseUrl.replace(/\/$/, "");
38479
38687
  const loginUrl = `${baseUrl}/login/canvas`;
@@ -38489,7 +38697,8 @@ async function sessionLogin(opts) {
38489
38697
  );
38490
38698
  }
38491
38699
  const html = await getRes.text();
38492
- const initialCookies = collectSetCookies(getRes.headers);
38700
+ const jar = new CookieJar();
38701
+ jar.absorb(getRes.headers);
38493
38702
  const authToken = extractAuthenticityToken(html);
38494
38703
  if (!authToken) {
38495
38704
  throw new SessionLoginError(
@@ -38511,14 +38720,14 @@ async function sessionLogin(opts) {
38511
38720
  "Content-Type": "application/x-www-form-urlencoded",
38512
38721
  Accept: "text/html,application/xhtml+xml",
38513
38722
  "User-Agent": USER_AGENT,
38514
- Cookie: serializeCookies(initialCookies),
38723
+ Cookie: jar.header(),
38515
38724
  Origin: baseUrl,
38516
38725
  Referer: loginUrl
38517
38726
  },
38518
38727
  body: body.toString()
38519
38728
  });
38520
- const respCookies = collectSetCookies(postRes.headers);
38521
- const hasCreds = respCookies.some((c) => c.name === "pseudonym_credentials");
38729
+ const respJar = parseCookieJar(postRes.headers.getSetCookie());
38730
+ const hasCreds = "pseudonym_credentials" in respJar.cookies;
38522
38731
  if (!hasCreds) {
38523
38732
  const location = postRes.headers.get("location") ?? "";
38524
38733
  if (location && !location.startsWith(baseUrl) && !location.includes("sso.canvaslms.com")) {
@@ -38530,10 +38739,8 @@ async function sessionLogin(opts) {
38530
38739
  "Login response did not include a pseudonym_credentials cookie \u2014 incorrect username or password, or the account is locked."
38531
38740
  );
38532
38741
  }
38533
- const sessionCookies = respCookies.filter(
38534
- (c) => ["canvas_session", "pseudonym_credentials", "_csrf_token", "log_session_id"].includes(c.name)
38535
- );
38536
- 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 };
38537
38744
  }
38538
38745
 
38539
38746
  // src/client.ts
@@ -38544,6 +38751,8 @@ var CanvasClient = class {
38544
38751
  sessionLoginFn;
38545
38752
  preloadedCookie;
38546
38753
  auth;
38754
+ /** Lazily-built shared refresh_token exchanger (oauth mode only). */
38755
+ oauthRefresh = null;
38547
38756
  /**
38548
38757
  * `preloaded` is the fetchproxy escape hatch: when set, the client uses
38549
38758
  * the supplied cookie header as-if it had just successfully run
@@ -38694,29 +38903,32 @@ var CanvasClient = class {
38694
38903
  });
38695
38904
  return result.cookie;
38696
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
+ */
38697
38915
  async refreshAccessToken(acct) {
38698
- const body = new URLSearchParams({
38699
- grant_type: "refresh_token",
38700
- client_id: acct.clientId,
38701
- client_secret: acct.clientSecret,
38702
- refresh_token: acct.refreshToken
38703
- }).toString();
38704
- const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
38705
- method: "POST",
38706
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
38707
- 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 }
38708
38920
  });
38709
- if (!res.ok) {
38710
- const errBody = await res.text();
38711
- throw new TokenExpiredError(
38712
- "oauth",
38713
- `${res.status} ${res.statusText}: ${truncateErrorMessage(errBody, 200)}`
38714
- );
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);
38715
38929
  }
38716
- const data = await res.json();
38717
- const expiresIn = data.expires_in ?? 3600;
38718
38930
  return {
38719
- headers: { Authorization: `Bearer ${data.access_token}` },
38931
+ headers: { Authorization: `Bearer ${accessToken}` },
38720
38932
  accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1e3
38721
38933
  };
38722
38934
  }
@@ -38778,16 +38990,7 @@ var FileExistsError = class extends Error {
38778
38990
  // src/tools/_shared.ts
38779
38991
  var textContent = textResult;
38780
38992
  function buildPath(base, params = {}) {
38781
- const parts = [];
38782
- for (const [key, value] of Object.entries(params)) {
38783
- if (value === void 0 || value === null) continue;
38784
- if (Array.isArray(value)) {
38785
- for (const v of value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
38786
- } else {
38787
- parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
38788
- }
38789
- }
38790
- return parts.length ? `${base}?${parts.join("&")}` : base;
38993
+ return `${base}${buildQueryString(params)}`;
38791
38994
  }
38792
38995
  function userSegment(observeeId) {
38793
38996
  return observeeId ? `users/${encodeURIComponent(observeeId)}` : "users/self";
@@ -39152,7 +39355,7 @@ try {
39152
39355
  }
39153
39356
  await runMcp({
39154
39357
  name: "canvas",
39155
- version: "1.1.7",
39358
+ version: "1.1.8",
39156
39359
  // x-release-please-version
39157
39360
  tools: [
39158
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.7', // 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.7",
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.10.3",
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",
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.7",
9
+ "version": "1.1.8",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.1.7",
14
+ "version": "1.1.8",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },